【Java核心基础知识】04 - 多线程并发(3)

多线程知识点目录

多线程并发(1)- https://www.jianshu.com/p/8fcfcac74033
多线程并发(2)-https://www.jianshu.com/p/a0c5095ad103
多线程并发(3)-https://www.jianshu.com/p/c5c3bbd42c35
多线程并发(4)-https://www.jianshu.com/p/e45807a9853e
多线程并发(5)-https://www.jianshu.com/p/5217588d82ba
多线程并发(6)-https://www.jianshu.com/p/d7c888a9c03c

七、Java后台线程(守护线程)

  1. 定义:守护线程,也称“服务线程”,是一种特殊的线程,这种线程不属于程序中不可或缺的一部分,当没有用户线程可服务时,后台线程会自动离开。

  2. 优先级:守护线程的优先级比较低,用来在后台为系统中其他对象和线程提供服务。

  3. 设置:通过setDaemon(true)来设置线程为“守护线程”。将一个用户线程设置为守护线程的方式是在线程对象创建之前,调用线程对象的setDaemon()方法。

  4. 在Daemon线程中产生的新线程也是Daemon线程。

  5. 线程是JVM级别的,独立于具体的Java应用程序,生命周期是由操作系统来管理。以Tomcat为例,它在Web应用中启动的线程并不会与Web应用保持同步的生命周期。即使你停止了Web应用,这个线程仍然会继续运行。

  6. 守护线程示例:垃圾回收线程。当程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器就无事可做,*所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。

  7. 生命周期:守护进程(Daemon)是运行在后台的一种特殊进程,它不与用户进行交互,也不依赖于终端。守护线程不会受到用户终端的控制,它会按照一定的周期或条件执行特定的任务,或者等待处理某些事件。守护线程不依赖于用户终端,但它依赖于操作系统。当系统运行时,守护线程会一直存在并执行任务,当系统关闭时,守护线程也会终止。如果JVM中的所有线程都是守护线程,那么JVM会认为没有需要继续运行的线程,因此可以退出。但如果还有非守护线程在运行,那么JVM会认为还有其他工作需要完成,因此不会退出。(简单来说:守护线程是一种在后台运行并执行特定任务的特殊线程。它是独立于用户终端的,但依赖于操作系统。当系统中没有非守护线程时,JVM会选择退出)

八、JAVA锁

8.1 乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,拿取数据时认为别人不会修改,所以不上锁,但是在更新的时候会判断一下在次期间别人有没有去更新这个数据,采取先读出当前版本号,然后加锁操作,比较跟上一次的版本号,一样则更新,如果失败则重读-比较-写操作。

Java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否相同,同则更新,否则失败。

8.2 悲观锁

悲观锁是一种悲观思想,即认为写多,遇到并发写的可能性高,拿取数据时认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。

Java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试CAS乐观锁去获取锁,获取不到才会转换为悲观锁,如RetreenLock。

8.3 自旋锁

自旋锁原理:如果持有锁的线程能再很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等待有锁的线程释放锁后立即获取,就避免用户线程和内核的切换的消耗。

线程自旋是需要消耗CPU的,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间仍没有释放锁,则会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋,进入阻塞状态。

8.4 Synchronized同步锁

synchronized可以把任意一个非NULL的对象当作锁。它属于独占式的悲观锁,同时属于可重入锁。

synchronized作用范围

  1. 作用于方法时,锁住的是对象的实例(this)
  2. 作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(JDK1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程
  3. 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。它有过个队列,当多个线程一起访问某个对象监视器时,对象监视器会将这些线程存储在不同的容器中。

synchronized核心组件

  1. Wait Set(等待集合):那些调用wait方法被阻塞的线程被放置在这里
  2. Contention List(锁竞争队列):竞争队列,所有请求锁的线程首先被放在这个竞争队列中
  3. Entry List(竞争候选列表):Contention List中那些有资格成为候选资源的线程被移动到Entry List中
  4. OnDeck(待处理队列):任意时刻,最多只有一个线程正在竞争锁资源,该线程被称为OnDeck
  5. Owner(持锁状态):当前已经获取到所有资源的线程称为Owner
  6. !Owner:当前释放锁的线程

synchronized实现机制

synchronized实现机制

8.5 ReentrantLock

ReentrantLock继承接口Lock并实现了接口中定义的方法,他是一种可重入锁,除了能完成synchronized所能完成的所有工作外,还提供了诸如可响应中断锁可轮询锁请求定时锁等避免多线程死锁的方法。

ReentrantLock特性
  • 可重入:意味着一个线程可以多次获取同一个锁,而不会产生死锁。

  • 公平锁:ReentrantLock 可以配置为公平锁和非公平锁。公平锁按照线程请求锁的顺序来分配锁,而非公平锁则没有这个限制。

  • 可中断:当一个线程持有锁时,其他线程可以尝试获取锁,如果获取失败,那么这个线程可以选择中断等待的线程。

  • 可尝试:可以尝试获取锁,而不会阻塞当前线程。

ReentrantLock使用
import java.util.concurrent.locks.ReentrantLock;  
  
public class ReentrantLockExample {  

    private final ReentrantLock lock = new ReentrantLock();  
  
    public void accessResource() {  
        // 获取锁
        lock.lock();  // block until condition holds  
        try {  
            // ... method body  
        } finally {  
            // 释放锁
            lock.unlock()  
        }  
    }  
}
公平锁 & 非公平锁

公平锁:通常先对锁提出获取请求的线程会先被分配到锁。
非公平锁:JVM按随机、就近原则分配锁的机制。

效率:非公平锁执行的效率要远远超出公平锁,除非程序有特殊需求,否则最常用非公平锁。

可以通过在创建 ReentrantLock 时设置参数来选择使用公平锁还是非公平锁,默认为非公平锁。

// 公平锁  -  锁会尽可能地按照线程请求的顺序分配
ReentrantLock fairLock = new ReentrantLock(true);  
  
// 非公平锁  
ReentrantLock unfairLock = new ReentrantLock(false);
ReentrantLock 与 Synchronized
  1. ReentrantLock通过方法lock()与unlock()来进行加锁与解锁操作,与Synchronized会被JVM自动解锁机制不同,ReentrantLock加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用ReentrantLock必须在finally控制块中进行解锁操作。
  2. ReentrantLock相比Synchronized的优势是可中断、公平锁、多个锁。这种情况下需要使用ReentrantLock。

8.6 Senaphore信号量

Semaphore是一种基于计数的信号量。它可以设定一个阀值,基于此,多个线程竞争获取许可信号,做完自己的申请后归还,超过阀值后,线程申请许可信号将会被阻塞。Semaphore可用来构建一些对象池、资源池之类的,比如数据库连接池。

实现互斥锁(计数器为1):创建计数为1的Semaphore,将其作为一种类似互斥锁的机制,这也叫二元信号量,表示两种互斥状态。

import java.util.concurrent.Semaphore;  
  
public class SemaphoreExample {  
    private static Semaphore semaphore = new Semaphore(3); // 允许3个线程同时访问共享资源  
  
    public static void main(String[] args) {  
        for (int i = 0; i < 5; i++) {  
            new Thread(() -> {  
                try {  
                    semaphore.acquire(); // 获取一个许可证,如果没有,线程将阻塞直到有一个可用  
                    System.out.println("Thread " + Thread.currentThread().getId() + " is accessing the resource.");  
                    Thread.sleep(1000); // 模拟资源访问时间  
                    System.out.println("Thread " + Thread.currentThread().getId() + " finished accessing the resource.");  
                    semaphore.release(); // 释放许可证,允许其他线程获取许可证并访问资源  
                } catch (InterruptedException e) {  
                    e.printStackTrace();  
                }  
            }).start();  
        }  
    }  
}

在这个例子中,我们创建了一个信号量 semaphore,初始化了3个许可证。然后我们创建了5个线程,每个线程都会尝试获取许可证来访问共享资源。如果当前没有可用的许可证,线程将会被阻塞直到有一个可用。当线程访问完共享资源后,它会释放一个许可证,这样其他线程就可以获取许可证并访问资源。

SemaphoreReentrantLock

Semaphore 基本能完成 ReentrantLock 的所有工作,使用方法也类似,通过acquire()与release()方法来获得和释放临界资源。

经实测,Semaphore.acquire()方法默认为可响应中断锁,与ReentrantLock.lockInterruptibly()作用效果一致,也就是说在等待临界资源的过程中可以被Thread.interrupt()方法中断。

此外,Semaphore也实现了可轮询的锁请求与定时锁的功能,除了方法名TryAcquire与tryLock不同,其使用方法与ReentrantLock几乎一致。Semaphore也提供了公平与非公平锁的机制,也可在构造函数中进行设定。

Semaphore 的锁释放操作也由手动进行,因此与 ReentrantLock 一样,为避免线程因抛出异常而无法正常释放锁的情况发生,释放锁的操作也必须在 finally 代码块中完成。

8.7 AtomicInteger

AtomicInteger是一个提供原子操作的Integer的类,常见的还有AtomicBoolean、AtomicLong、AtomicReference等,他们的实现原理相同,区别在与运算对象类型不同。还可以通过AtomicReference将一个对象的所有操作转化成原子操作。
在多线程程序中,诸如++i或i++等运算不具有原子性,是不安全的线程操作之一。通常我们会使用Synchronized将操作变成一个原子操作,但JVM为此类操作特意提供了一些同步类,使得使用更方便,且使程序运行效率更高。

8.8 ReadWriteLock读写锁

Java提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制。

如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。多个读锁不互斥,读锁与写锁互斥,这是由JVM控制的,无需我们来控制。

九、线程上下文切换

时间片轮转(或称为时间片调度)是一种策略,CPU 依次为每个任务提供一段时间的服务,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务,任务的状态保存及再加载, 这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能。


线程上下文切换
1)进程

是指一个程序运行的实例(又称任务)。在Linux系统中,线程就是能并行运行并且与他们的父进程(创建他们的进程)共享同一地址空间(一段内存区域)和其他资源的轻量级的进程。

2)上下文

指某一时间点CPU寄存器和程序计数器的内容

3)寄存器

CPU内部的数量较少但速度很快的内存(与之对应的是CPU外部相对较慢的RAM主内存)。寄存器通过对常用值的快速访问来提高计算机程序运行的速度。

4)程序计数器

一个专用的寄存器,用于表明指令序列中CPU正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体依赖于特定的系统。

5)PCB-“切换帧”

上下文切换可以认为是内核在CPU上对进程进行切换,上下文切换过程中的信息是保存在进程控制块(PCB, Process Control Block)中的。PCB还经常被称作“切换帧”。信息会一直保存到CPU的内存中,知道他们被再次使用。

6)上下文切换的活动
  1. 挂起一个进程,将这个进程在CPU中的状态(上下文)存储于内存中的某处。
  2. 在内存中检索下一个进程的上下文并将其在CPU的寄存器中回复。
  3. 跳转到程序计数器所指向的位置(即进程被中断时的代码行),以恢复该进程在程序中。
7)引起上下文切换的原因
  1. 当前执行任务的时间片用完之后,系统CPU正常调度下一个任务;
  2. 当前执行任务碰到IO阻塞,调度器将此任务挂起,继续下一任务;
  3. 多个任务抢占锁资源,当前任务没有抢到锁资源,被调度器挂起,继续下一任务;
  4. 用户代码挂起当前任务,让出CPU时间;
  5. 硬件终端。

十、线程池原理

线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等待,等其他线程执行完毕,再从队列中取出任务来执行。它的主要特点是:线程复用控制最大并发数管理线程

10.1 线程复用

每一个Thread的类都有一个start()方法。当调用start()启动线程时Java虚拟机会调用该类的run()方法。那么该类的run()方法中就是调用了Runnable对象的run()方法。我们可以继承重写Thread类,再其start()方法中添加不断循环调用传递过来的Runnable对象。这就是线程池的实现原理。循环方法中不断获取Runnable是用Queue实现的,在获取下一个Runnable之前可以是阻塞的。

10.2 线程池的组成

  1. 线程池管理器:用于创建并管理线程池
  2. 工作线程:线程池中的线程
  3. 任务接口:每个任务必须实现的接口,用于工作线程调度其运行
  4. 任务队列:用于存放待处理的任务,提供一种缓冲机制

Java中的线程池是通过Executor框架实现的,该框架中用到了Executor、Executors、ExecutorService、ThreadPoolExecutor、Callable和Future、FutureTask这几个类。


Executor框架

10.3 拒绝策略

线程池中的线程已经用完了,无法继续为新任务服务,同时,等待队列也已经排满了,再也塞不下新任务了。这时候,我们就需要拒绝策略机制合理的处理这个问题。
JDK内置的拒绝策略:

  1. AbortPolicy:直接抛出异常,组织系统正常运行。
  2. CallerRunsPolicy:只要线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。显然这样做不会真的丢弃任务,但是,任务提交线程的性能极有可能会急剧下降。
  3. DiscardOldestPolicy:丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务。
  4. DiscardPolicy:丢弃无法处理的任务,不予任何处理。如果运行任务丢失,这是最好的一种方案。

以上内置拒绝策略均实现了RejectedExecutionHandler接口,若以上策略仍无法满足实际需要,可拓展RejectedExecutionHandler接口。

10.4 Java线程池工作过程

  1. 线程池刚创建时,里面没有线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
  2. 当调用execute()方法添加一个任务时,线程池会做如下判断:
  • (1)如果正在运行的线程数量小于corePoolSize,那么马上创建线程运行这个任务;
  • (2)如果正在运行的线程数大于或等于corePoolSize,那么将这个任务放入队列。
  • (3)如果这时候队列满了,而且正在运行的线程数量小于maximimPoolSize,那么还是要创建非核心线程立即运行这个任务。
  • (4)如果队列满了,而且正在运行的线程数量大于或等于maximumPoolSize,那么线程池会抛出异常RejectExecutionExeption。
  1. 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  2. 当一个线程无事可做,超过一定时间(KeepAliveTime)时,线程池会判断,如果当前运行的线程大于CorePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会缩到CorePoolSize的大小。

你可能感兴趣的:(【Java核心基础知识】04 - 多线程并发(3))