当前位置:   article > 正文

JAVA多线程知识整理_java semaphore 阻塞等待会创建线程吗?

java semaphore 阻塞等待会创建线程吗?

Java多线程基础

线程的创建和启动

继承Thread类来创建并启动
  • 自定义Thread类的子类,并重写该类的run()方法,该run()方法实际上就是线程执行体,代表了线程需要完成的任务。
  • 创建该子类的实例,即创建线程对象。
  • 调用线程对象的start方法来启动该线程。
实现Runnable接口创建线程类
  • 自定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法同样是该线程的线程执行体。

  • 创建该实现类的实例,并以此实例来创建Thread对象。

    MyRunnable mr = new MyRunnable();
    new Thread(mr);
    
    • 1
    • 2
  • 调用线程对象的start方法来启动该线程。

实现Callable和FutureTask创建线程
  • 创建Callable接口的实现类并实现call()方法,该方法就是线程执行体,有返回值。

  • 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象call()方法的返回值。

    FutureTask<Integer> futureTask = new FutureTask<>(new MyCallable());
    
    • 1
  • 使用该FutureTask对象来创建Thread对象。

    new Thread(futureTask);
    
    • 1
  • 使用FutureTask对象的get()方法来获取子线程执行结束后的返回值。

对比上述三个创建线程的方式
  • 使用Thread创建线程
    • 优势:代码简单,如果需要访问当前线程无需使用Thread.currentThread()方法,而是直接使用this即可获取当前线程。
    • 劣势:线程继承了Thread父类,无法继承其他父类。
  • 实现Runnable和实现Callable接口
    • 优势:
      • 线程类只是实现了Runnable或Callable接口,还可以继承其他类。
      • 多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况。
      • 数据和代码独立,增加程序的健壮性。
      • 线程池只能放入Runnable或者Callable接口的实现类,而不是直接放入Thread的子类中。
    • 劣势:代码编写复杂,如果访问当前线程必须使用Thread.currentThread()方法。
  • 对比实现Runnable接口和Callable接口
    • Runnable重写的是run(),Callable重写的是call()。
    • run()无返回值,call()执行后可以有返回值。
    • run()不能抛异常,而call()可以。
    • 运行一个Callable任务可以拿到一个FutureTask对象用于表示异步计算的结果。通过该FutureTask对象可以了解任务的执行情况,取消任务的执行,获取执行结果。
使用线程池创建
  • 使用Executors类中的newFixedThreadPool(int num)方法来创建一个线程数量为num的线程池。

  • 调用线程池中的execute()方法执行由Runnable接口创建的线程;调用submit()方法来执行有Callable接口创建的线程。

  • 使用shutdown()方法关闭线程池。

  • 阿里Java开发手册明确规定不能用Executors去创建线程,而是通过ThreadPoolExecutor的方法。

    • FixedThreadPool和SingleThreadPool允许请求队列的长度为Integer.MAX_VALUE,可能会堆积大量的请求导致OOM。

    • CachedThreadPool和ScheduledTheadPool允许创建线程的数量为Integer.MAX_VALUE,可能会创建大量的线程导致OOM。

线程的七种状态

线程的七种状态:新建(NEW)、就绪(READY)、运行(RUNNABLE)、阻塞(BLOCKED)、终止(TERMINATED)、等待(WAITING)、超时等待(TIME_WAITING)。

  • 新建:使用new关键字创建了一个线程之后,线程就处于新建状态。此时仅由Java虚拟机为其分配内存,并初始化其成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。

  • 就绪:线程对象调用start()方法之后,线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。

    • 调用start()、调用yield()
    • sleep()结束、join()结束
  • 运行:处于就绪状态的线程获得了CPU,开始执行线程执行体,线程处于运行状态。如果计算机只有一个CPU,那么任何时刻只有一个线程处于运行状态。在一个多处理器的机器上,将会有多个线程并行执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。

  • 阻塞:

    • 线程调用了一个阻塞式IO的方法,在该方法返回之前线程被阻塞。
    • 线程试图获取一个同步监视器,但是该同步监视器被其他线程持有。
    • 调用了线程的suspend()方法将该线程挂起(会导致死锁)
  • 终止:

    • 线程执行体执行完成,线程正常结束。
    • 线程出现未捕获的Exception或者Error。
    • 直接调用该线程的stop()方法结束该线程(会导致死锁)
  • 等待:一个线程获得了锁,但是需要等待其他线程执行某些操作,这个等待时间是不确定的。

    • 调用Object的wait()方法
    • 调用Thread对象的join()方法
    • 调用LockSupport对象的park方法。
  • 超时等待:与等待的区别就是,该等待时间是确定的。

一些其他要注意的点:

  • 线程从阻塞和等待状态只能进入就绪状态,无法直接进入运行状态。
  • 就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定,当处于就绪状态的线程获得处理器资源时,该线程进入运行状态;当处于运行状态的线程失去处理器资源时,该线程进入就绪状态。
  • 为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪、运行、阻塞、等待返回true;当线程处于新建、死亡两种状态时返回false。
  • 不要试图对一个已经死亡的线程调用start()方法使它重新启动,对新建状态的线程两次调用start()方法也是错误的。这都会引起IllegalThreadStateException异常。

线程同步和线程通信

线程同步:即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多,临界区对象就是其中一种。

join()

Thread提供的让一个线程等待另一个线程完成的方法。某个程序执行中的线程A调用了线程B的join方法,则线程A进入等待状态,直到线程B执行完成后,才会继续执行。join()是会释放锁的

  • public final void join() throws InterruptedException 这个是一直等待这个线程终止。
  • public final void join(long millis) throws InterruptedException 等待这个线程死亡的时间最多为millis毫秒,如果为0表示一直等待。如果是负数则直接抛出IllegalArgumentException异常。
  • public final void join(long millis, int nanos) throws InterruptedException 等待最多millis毫秒加上这个线程死亡的nanos纳秒,如果millis为负数或者nanos不在0-999999范围则直接抛出IllegalArgumentException异常。
sleep()
  • Thread类中的静态方法,当一个执行中的线程调用了Thread的sleep()方法,调用线程会进入阻塞状态而让出CPU,但是不释放锁。如果时间到了就会正常返回,然后线程处于就绪状态参与CPU调度,获取到CPU时间片就可以执行。

  • 该方法声明抛出了InterrupedException。

  • Thread.sleep(0)表示这次调用该方法的线程被冻结了一下,让其他线程有机会执行。即重新触发一次CPU竞争(依然按照线程的优先级)

yield()
  • 让当前线程暂停,但不会阻塞,而是进入就绪状态。有可能刚调用了该方法,又获得了CPU时间片。
  • 不释放锁,只会让同优先级或者优先级更高的的线程执行机会。
  • 无任何异常抛出。
wait()与notify()与notifyAll()

线程通信的方式之一,这三个方法必须由同步监视器对象来调用

  • 对于使用synchronized修饰的同步方法,因为该类的默认实例(this)就是同步监视器,所以可以在同步方法中直接调用这三个方法。
  • 对于使用synchronized修饰的同步代码块,同步监视器是synchronized后括号里的对象,所以必须使用该对象调用这三个方法。
    • wait() 导致当前线程等待,并释放锁。直到其他线程调用该同步监视器的notify()或notifyAll()方法来唤醒该线程。该wait()若不加参数,则一直等待直到其他线程通知,带参数可以是毫秒也可以是毫秒加微秒。
    • notify() 唤醒在此同步监视器上等待的单个线程让其进入就绪状态。如果所有线程都在此同步监视器上等待,则会唤醒其中一个线程。选择是任意性的。只有当前线程放弃对该同步监视器的锁定后,才可以执行被唤醒的线程。
    • notifyAll() 唤醒再此同步监视器上等待的所有线程。
守护线程(后台线程)

该线程的存在是为其他线程提供服务的。JVM的垃圾回收线程就是典型的守护线程。其有个典型的特征:如果所有的前台线程都死亡,则后台线程自动死亡。当整个虚拟机只剩下后台线程时,程序就没有运行的必要,直接退出。

  • Thread对象的setDaemon(true)可以将指定的线程设置为后台线程。该方法必须在start()方法之前执行,否则会抛出IllegalThreadStateException异常,但该线程依然会执行,只不过不是作为守护线程而是正常的用户线程。
  • Thread对象的isDaemon()用于判定当前线程是否为后台线程。
  • 在守护线程中产生的线程也是守护线程。守护线程并不是所有任务都能执行,例如读写操作或者计算逻辑。
  • 守护线程中不能依靠finally块的内容来确保执行关闭或者清理资源逻辑。也就是说一个守护线程,里面的finally不一定被执行,因为一旦所有用户线程都结束运行,守护线程会随JVM一起结束工作,那么finally自然就不会被执行了。
线程优先级

每个线程都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。Thread类提供了setPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级,其中setPriority()方法的参数可以是一个整数,范围在1-10之间(1的优先级最低,10的最高),也可以使用MAX_PRIORITY值为10、MIN_PRIORITY值为1、NORM_PRIORITY值为5。一般不建议乱设置。

死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。其中,Java虚拟机没有监测,也没有采取措施来处理死锁情况,所以多线程编程时应当避免死锁的出现。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

public class DeadLockDemo {

    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + " get resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + " waiting get resource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + " get resource2");
                }
            }
        }, "线程1").start();
        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + " get resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + " waiting get resource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + " get resource1");
                }
            }
        }, "线程2").start();
    }
}
输出结果:
Thread[线程2,5,main] get resource2
Thread[线程1,5,main] get resource1
Thread[线程2,5,main] waiting get resource1
Thread[线程1,5,main] waiting get resource2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41

看代码不难看出,首先线程2获得了锁synchronized (resource2),然后由通过Thread.sleep(1000)休眠1s让线程1开始执行,然后线程1执行完后也进行了Thread.sleep(1000)休眠1s。这两个线程休眠结束后都企图去获得对方所持有的资源而陷入互相等待的状态,这就造成了死锁。

产生死锁的四个必要条件
  • 互斥:一个资源一次只允许一个进程使用。
  • 请求和保持:一个进程因请求资源而被阻塞时,对已有的资源不放。
  • 不可剥夺:进程获取资源未使用完不能被强行剥夺。
  • 循环等待:若干进程之间形成一种头尾相连的循环等待资源关系。
避免死锁
  • 全部可用:所有资源可以被自由的访问。
  • 资源一次分配:一次性分配所有的资源。
  • 资源可抢占:某个进程获得了部分资源,但得不到其他资源,则释放已占有的资源。
  • 资源有序分配:给每个资源编号,每个进程按编号顺序请求资源,释放则相反。
银行家算法详见操作系统整理

线程通信

传统的线程通信

借助Object类的wait()、notify()、notifyAll()三种方法。

使用Condition控制线程通信

如果程序不使用synchronized关键字来保证同步,而是直接使用Lock对象来保证同步,则系统中不存在隐式的同步监视器,即不能用上述三种方法。 Condition类提供了await()、signal()、signalAll()三种方法,这三种方法与上述三个方法类似。获取指定Lock对象对应的Condition:private final Condition cond=lock.newCondition()下面直接用cond调用方法即可。

使用阻塞队列控制线程通信

当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该进程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。

面试题:如何定义一个对象线程安全?如何保证一个对象线程安全?

  • 当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要额外的同步,或者在调用方式进行任何其他的协调操作,调用这个对象的行为都可以获取到正确的结果。出现线程安全的问题一般是因为主存和工作内存数据不一致性和重排序导致的。
  • 保证线程安全以是否需要同步手段分类,分为同步方案和非同步方案。

img

  • 互斥同步:最常见的一种并发正确性的保证手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。
    • 最基本的就是synchronized关键字,synchronized关键字编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码,获取资源必须拿到锁。
    • ReentrantLock也是通过互斥来实现同步。
    • 互斥同步的主要问题就是进行线程阻塞和唤醒带来的性能问题,因此这种同步也称为阻塞同步。从处理问题的方式来说,互斥同步属于一种悲观的并发策略,它总是认为只要不加锁就会出现线程安全问题。
  • 非阻塞同步:先进行操作,如果没有其他线程并发操作则操作成功;如果有,产生了冲突那就再采用其他的补偿措施(不断重试直到成功为止)。
    • CAS
    • 读锁
  • 无须同步方案:只要保证某操作不涉及共享数据,那么它自然无需任何同步操作去保证正确性。

面试题:线程通信有什么问题?怎么解决?

  • 如果线程A修改了主存中的某一数据但是没有及时写回主存,而线程B又去进行读取数据操作,则读取到的是过期的数据。

    主内存中i = 0
    线程1load i from 主存    // i = 0
    		i + 1  // i = 1
    线程2load i from 主存  // 线程1还没将i的值写回主存,所以i还是0
    		i + 1  // i = 1
    线程1:  save i to 主存
    线程2:  save i to 主存
    现在主存中的值还是1,可我们的预期值是2
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
  • 该问题可以通过同步机制(控制不同线程间操作发生的相对顺序)或者通过volatile关键字使每次修改都能够及时强制刷新到主存,从而对每个线程可见。


Java多线程深入

JMM详解

JMM是什么
  • JMM(Java内存模型)是一个抽象的概念:描述的一组围绕原子性、有序性、可见性的规范。其定义了程序中各个变量的访问规则,即虚拟机中将变量存储到内存中取出变量这样的底层细节。此处的变量是共享变量。
  • JMM规定:所有共享变量存储在主内存中,每条线程有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本,线程对变量的所有操作都必须在工作内存上进行,线程不能直接读写主内存的共享变量。不同的线程之间也无法访问对方工作内存中的变量,线程间的变量值的传递均需通过主内存来完成
在这里插入图片描述
内存间的相互操作
  • lock(锁定):作用于主内存中的变量,把一个变量表示为一个线程独占的状态。
  • unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,把一个变量的值从主内存读取到线程的工作内存中,以便于后面的load操作。
  • load(载入):作用于工作内存中的变量,把read操作从主存中得到的变量值放入工作内存中的变量副本。
  • use(使用):作用于工作内存中的变量,把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就执行这个操作。
  • store(存储):作用于工作内存中的变量,把工作内存中一个变量的值传送给主存中以便于后面的write操作。
  • write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。

在这里插入图片描述

JMM的三大特性
  • 原子性:对于基本数据类型的读取和赋值操作都是原子性操作。即这些操作是不可中断的,要么做完,要么不做。

    • 经典案例:如果有两个线程同时对i进行赋值,一个赋值为1,另一个为-1,则i的值要么为1要么为-1。

      i = 2;      //1
      j = i;      //2
      i++;        //3
      i = i + 1//4
      其中,1是赋值操作,是原子操作,而234都不是原子操作。
      2是读取赋值
      34都是读取,修改,赋值
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    • JMM只保证单个操作具有原子性,并不保证整体原子性。保证整体原子性可以使用Atomic下的类或者synchronized。

  • 可见性:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

    • JMM通过在遍历修改后将新值同步回主存,在遍历读取前从主内存刷新遍历值来实现可见性。
    • 实现方式:
      • volatile:通过在指令中添加lock指令,以实现内存可见性。其特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,其保证了多线程操作时变量的可见性。
      • synchronized:当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主存中。
      • final:被final关键字修饰的字段在构造器中一旦初始化完成,并且没有发生this逃逸(其他线程通过this引用访问到初始化了一半的对象),那么其他线程就能看见final字段的值。
  • 有序性:在本线程内观察,所有的操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的。无序是因为发生了指令重排序和工作内存与主内存同步延迟。

    • 实现方式:
      • volatile关键字通过添加内存屏障的方式来禁止指令重排序,即重排序时不能把后面的指令放到内存屏障之前。
      • synchronized关键字同样可以保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码
  • 其中,synchronized具有原子性、可见性、有序性;volatile具有有序性和可见性;final具有可见性

指令重排序和数据依赖性

现在的CPU都是采用流水线来执行指令的,一个指令的执行有:取指、移码、执行、访存、写回五个阶段,多条指令可以同时存在流水线中同时被执行。流水线是并行的,也就是说不会在一条指令上耗费很多时间而导致后续的指令都卡在执行之前的阶段。我们编写的程序都要经过优化后(编译和处理器对我们编写的程序进行优化后以提高效率)才被运行。优化分为很多种,其中一种就是重排序。即重排序就是为了提高性能。在JMM中,允许编译器和处理器对指令进行重排序,重排序的过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

img

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

  • 指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  • 内存系统的重排序:由于处理器使用缓存和IO缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

重排序的两大规则:as-if-serial规则和happens-before规则

  • as-if-serial:指令不管怎么重排序,单线程程序的执行结果不能被改变

    • 编译器,JRE和处理器必须遵守该规则。编译器和处理器不会对存在数据依赖关系的操作做重排序,如果不存在数据依赖关系,那么这些操作可能被编译器和处理器重排序。

    • 数据依赖性:如果两个操作访问同一个变量,且这两个操作有至少有一个为写操作,此时这两个操作就存在数据依赖性

      • 读后写、写后写、写后读。只要重排序两个操作的执行顺序,那么程序的执行结果将会被改变。
      • 如果重排序会对最终执行结果产生影响,编译器和处理器在重排时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。例如计算长方形面积的程序,长宽变量没有任何关系,执行顺序改变也不会对最终结果造成任何的影响,所以可以说长宽没有数据依赖性:
      int a = 2;  //A
      int b = 4;  //B
      int c = a * b;  //C
      
      • 1
      • 2
      • 3

      AC存在数据依赖关系,BC也存在,而AB不存在,所以在最终执行指令序列的时候,C不能排在AB的前面(这样会改变程序的结果),但是AB并没有数据依赖性关系。也就是说编译器和处理器可以重排AB之间的执行顺序:先B后A,先A后B都可以。as-if-serial规则把单线程程序保护了起来,这也就就是说遵守as-if-serial语义的编译器、JRE和处理器给了我们一个幻觉:单线程的程序是按照顺序来执行的。其实并不是,as-if-serial语义使程序员无需担心重排序的影响,也无须担心内存可见性的问题。

  • happens-before:如果操作A先行发生与操作B,则在操作B发生之前,操作A的影响(修改主内存中共享变量的值、调用方法等)是操作B可见的。

    • JMM向我们保证:如果线程A的写操作write和线程B的读操作read之间存在happens-before关系,尽管write和read在不同的线程中执行,但JMM向程序员保证write操作对read操作可见。

    • JMM规定的天然现行发生关系,如果两个操作之间没有下面的关系,且无法从下面的关系推导,则JVM可以对其进行随意的重排序:

      • 程序次序规则:在一个线程内,控制流(循环,分支)顺序在程序前面的操作先行发生于后面的操作
      • 管程锁定原则:一个unlock操作先行发生与后面对用一个锁的lock操作
      • volatile变量规则:对一个volatile遍历的写操作先行发生于后面对这个变量的读操作
      • 线程启动规则(start规则):Thread对象的start()方法调用先行发生于此线程的每一个动作
      • 线程加入规则(join规则):Thread对象的结束先行发生于join()方法返回
      • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过interrupted()方法检测到是否有中断发生
      • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
      • 传递性:如果操作A先行发生于操作B,操作B先行发生与操作C,则操作A先行发生于操作C。
    • 两个操作之间存在happens-before关系,并不意味具体实现时必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果与按happens-before关系来执行的结果一致。那么这种重排序在JMM之中是被允许的。

重排序带来的问题:

class ReorderExample {
	int a = 0;
	boolean flag = false;

	public void writer() {
		a = 1;                   //1
		flag = true;             //2
	}

	public void reader() {
		if (flag) {              //3
			int i =  a * a;      //4
			……
		}
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

开两个线程AB,分别执行writer和reader,flag为标志位,用来判断a是否被写入,则我们的线程B执行4操作时,能否看到线程A对a的写操作?不一定,12操作并没有数据依赖性,编译器和处理器可以对这两个操作进行重排序,也就是说可能A执行2后,B直接执行3,判断为true,接着执行4,而此时a还没有被写入,这样多线程程序的语义就被重排序破坏了。

编译器和处理器可能会对操作重排序,这个是要遵守数据依赖性的,即不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑,所以在并发编程下这就有一些问题了。volatile关键字利用内存屏障来禁止重排序带来的问题

volatile关键字深入理解

volatile简介与实现原理
  • volatile一般用于修饰会被不同线程访问和修改的变量,而针对volatile修饰的变量给JVM给了规定:线程对volatile变量的修改会立刻被其他线程感知,即被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,这样就不会出现数据脏读的现象,保证了数据的可见性。其还禁止了指令重排序。总的来说就是按需禁用缓存以及编译器优化
  • 实现原理:加入volatile关键字的代码的class字节码中会多出了一个lock前缀指令,lock指令相当于一个内存屏障。该指令主要做了以下两件事:
    • 重排序时不能把后面的指令重排序到内存屏障之前的位置。
    • 将当前处理器缓存行的数据写回系统内存,这个写回内存的操作会使其他CPU里缓存的该内存地址的数据无效,即新写入的值对别的线程可见。
  • volatile实现了缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
  • 如果编译器经过分析后,认定一个volatile变量只会被单个线程访问,那么编译器可以把这个volatile变量当做一个普通的变量来对待。
volatile与内存屏障

为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序:

在这里插入图片描述
  • 四种内存屏障

    屏障类型指令类型说明
    LoadLoadBarriersLoad1;LoadLoad;Load2确保Load1的数据的装载先于Load2及所有后续装载指令的装载。
    StoreStoreBarriersStore1;StoreStore;Store2确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储。
    LoadStoreBarriersLoad1;LoadStore;Store2确保Load1的数据的装载先于Store2及所有后续存储指令的存储
    StoreLoadBarriersStore1;StoreLoad;Load2确保Store1的数据对其他处理器可见(刷新到内存)先于Load2及所有后续的装载指令的装载
    • 在每个volatile写操作的前面插入一个StoreStore屏障,禁止上面的普通写和下面的volatile写重排序。
    • 在每个volatile写操作的后面插入一个StoreLoad屏障,禁止上面的volatile写与下面可能有的volatile读/写重排序。
    • 在每个volatile读操作的后面插入一个LoadLoad屏障,禁止下面所有的普通读操作和上面的volatile读重排序。
    • 在每个volatile读操作的后面插入一个LoadStore屏障,禁止下面所有的普通写操作和上面的volatile读重排序。
  • 对于JMM的八种操作,volatile规定:read(读取)、load(载入)、use(使用)动作必须连续出现;assign(赋值)、store(存储)、write(写入)动作必须连续出现。即每次读取前必须先从主内存刷新最新的值,每次写入后必须立即同步回主内存当中

缓存行的其他问题

缓存行:为了增加CPU的访问速度,通常会在CPU和内存之间增加多级缓存:

img L3为全局缓存,L2L1为核心独享的缓存。

根据局部性原理,CPU每次访问主存时都会读取至少一个缓存行的数据(通常一个缓存行为64字节,哪怕读取4字节数据,也会连续的读取该数据之后的60个字节)。

volatile的底层实际上就是加一个lock指令,**lock指令会锁定共享变量所在的所有缓存行,变量更新完成同步回内存后再释放。**这样就会产生一个性能问题,当一个线程更新变量时,其他线程都无法对该变量的相邻变量操作了(相邻变量被预读取在同一缓存行)。

面试题:count++是原子性操作吗?
public class VolatileExample {

	private static volatile int counter = 0;

	public static void main(String[] args) {
		//开十个线程,让他们每个都自增10000次,理论上应该得到10000
		for (int i = 0; i < 10; i++) {
			Thread thread = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int i = 0; i < 1000; i++)
						counter++;
				}
			});
			thread.start();
		}
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(counter);
	}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

多次运行上述代码,每次都得不到10000,这说明volatile并不能保证整体原子性,问题就是counter++不是一个原子性操作:

  • 读取counter的值
  • 对counter+1
  • 将新的值赋给变量counter

如果线程1读取counter到工作内存后,其他线程对这个值已经做了自增操作,那么线程A的这个值自然就是一个过期的值,造成了数据的脏读,因此结果必然小于10000。如果想让volatile保证整体原子性,必须符合:

  • 运算结果不依赖变量当前的值,或者能够确保只有一个线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

synchronized关键字详解

synchronized关键字使每个线程依次排队操作共享变量,也就是用来处理共享数据的安全性问题。不过这种同步机制的效率很低。

作用范围:代码块和方法
  • 方法
    • 实例方法:被锁的是该类的实例对象
      • public synchronized void method() {}
    • 静态方法:被锁的是类对象
      • public static synchronized void nethod() {}
  • 代码块
    • 实例对象:被锁的是类的实例对象
      • synchronized (this) {}
    • class对象:被锁的是类对象
      • synchronized (Test.class) {}
    • 任意实例对象Object:被锁的是配置的实例对象
      • String test = “1111”; synchronized (test) {}
  • 如果需要锁变量,则需要把基础类型变成包装类型。

如果锁的是类对象的话,不管new多少个实例对象,他们都会被锁住,即线程之间保证同步关系。其实无论对一个对象进行加锁还是对一个方法进行加锁,实际上都是对对象进行加锁。被加了锁的对象就叫锁对象,在Java中任何一个对象都能成为锁对象。

执行原理
  • 给代码块添加synchronized关键字,查看字节码文件,发现执行同步代码块要先执行monitorenter指令,退出的时候执行monitorexit指令。且只执行一次monitorenter,这是因为synchronized具有重入性,同一个锁程中,线程不需要再次获取同一把锁
  • 使用synchronized进行同步,关键就是必须要获取到对象的监视器monitor,只有获取到该监视器的线程才会继续往下执行,否则就只能等待,且该过程是互斥的,同一时刻只能由一个线程能获取到monitor。获取失败的线程状态变为BLOCKED,进入阻塞队列,当该对象的监视器被释放后,阻塞队列的线程才有机会重新获取该监视器。
synchronized的优化(锁膨胀)
  • synchronized的问题:保证同一时刻只有一个线程能够获取到对象的监视器,从而进入到同步代码块或者同步方法中,表现为互斥性。这种方式的效率十分低下,每次只能过一个线程。在jdk1.6之后对synchronized进行优化。synchronized的优化,其实就是锁的四种状态的转变。

  • 锁的四种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。 这几个状态会随着竞争情况而逐渐升级,锁可以升级但不能降级。这种升级不降级的策略目的是为了提高获得锁和释放锁的效率。

    在这里插入图片描述

    • 偏向锁:大多数情况下,锁不仅不存在多个线程竞争,而且总是由同一个线程多次获得。使用偏向锁就是减少无竞争且只有一个线程使用锁产生的性能消耗。如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
      • 偏向锁的获得:
        • 当一个线程访问同步块并获取锁时,会在对象头和自己的栈帧中的锁记录里存储偏向该线程的ID,以后该线程在进入和退出同步块时就不需要进行CAS操作来加锁和解锁了,只需要测试一下对象头的Mark Word中是否存储指向该线程的ID。如果测试成功就表示线程已经获得了锁,直接执行同步块。如果测试失败则还需要测试一下偏向锁的标识是否为1(标识当前是线程是否获得偏向锁):
          • 如果没有获得偏向锁,使用CAS竞争锁,此时偏向锁膨胀为轻量级锁;
          • 如果获得了偏向锁,表明是进行初始化的时候:使用CAS将对象头的偏向锁指向当前线程。这也就是说偏向锁只有在初始化的时候进行一次CAS操作即可。
      • 偏向锁的释放:
        • 偏向锁使用了一种等到竞争才释放锁的机制,即当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。可如果有两个线程进行竞争的时候,偏向锁就失效了膨胀为轻量级锁了。
        • 释放需要等待全局安全点(此时没有正在执行的字节码)暂停拥有偏向锁的线程,检查持有偏向锁的线程是否还活着:
          • 如果没活,则将对象头设置为无锁。
          • 如果活着,拥有偏向锁的栈会被执行。遍历对象的锁记录,线程栈中的锁记录和对象头的Mark Word要么重新偏向其他线程,要么恢复到无锁状态或者标记该对象不适合作为偏向锁,最后唤醒暂停的线程。
      • 如果某些同步代码块大多数情况下都是由两个或以上的线程竞争的话,偏向锁就是个累赘了(有额外锁撤销产生的开销),对于这种情况,我们一开始关闭即可。通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
    • 轻量级锁:轻量级锁是一种非阻塞同步的乐观锁,因为这个过程并没有挂起阻塞线程,而是让线程空循环等待,串行执行
      • 轻量级锁是由偏向锁膨胀而来的:
        • 线程在自己的栈帧中创建锁记录LockRecord(开辟位置)
        • 将锁对象的对象头的MarkWord复制到线程刚刚创建的锁记录中(复制锁信息)
        • 将锁记录中的Owner指针指向锁对象(让线程指向锁)
        • 将锁对象的对象头的MarkWord替换为指向锁记录的指针(让锁指向线程)
        • 锁对象对象头的Mark Word的锁标志位变成00,即表示轻量级锁0
      • 轻量级锁有两种实现:
        • 自旋锁:**如果线程1持有锁,线程2来竞争的时候会在原地自旋而不是阻塞。如果线程1释放锁,则线程2能立刻获得锁。**线程在原地循环等待会消耗cpu,相当于执行一个什么都没有的for循环。
          • 如果同步代码块执行的很慢,需要消耗大量的时间,则此时在原地自旋等待的其他线程就很消耗CPU。
          • 如果只有两个线程,一个线程释放锁后另一个能立马获得,可如果好几个线程都在竞争,这就会导致一些线程始终获取不到锁而在原地循环等待消耗CPU。**针对这种情况,我们给线程的空循环等待设置一个次数,如果线程循环超过该次数使用自旋锁就不合适了,进行锁膨胀,将锁升级为重量级锁。**默认循环次数是10,可以通过-XX:PreBlockSpin参数来修改。
        • 自适应自旋锁:线程空循环等待的自旋次数并非固定,而是动态的根据实际情况来改变。
          • 线程1刚获得一个锁,当其释放后,线程2获得锁。在线程2运行的过程中,线程1又想获得锁了,不过线程2并没有释放,则线程1原地等待。JVM认为,由于线程1刚刚获得过锁,则线程1这次自旋也是很有可能再次成功的获得该锁,所以会适当的延长线程1的自旋次数。
          • 对应的,如果一个线程自旋后很少有机会获得该锁,则以后该线程要获取锁时直接忽略掉自旋过程,直接升级为重量级锁。
    • 重量级锁:轻量级锁膨胀后成为重量级锁,依赖对象内部的monitor来实现。当系统检查到锁是重量级锁后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不消耗cpu,但是阻塞或者唤醒一个线程时都需要操作系统来帮忙,即从用户态转换到内核态。这个转换是要消耗很多时间的,有可能比用户执行代码的时间还要长
  • synchronized并非一开始就给该对象加上重量级锁,而是从偏向锁到轻量级锁再到重量级锁的演变。假如我们一开始就知道某个同步代码块竞争很激烈的话,那么我们一开始就要使用重量级锁,从而减少锁转换的开销。如果我们只有一个线程在运行,那偏向锁则是一个很好的选择。而当某个同步代码块竞争不是那么很激烈的时候,我们就可以考虑使用轻量级锁。

synchronized底层实现原理
在这里插入图片描述
  • CXQ队列(_cxq):竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。CXQ队列是一个临界资源,并不是一个真正的Queue,只是一个虚拟队列,由Node和next指针构成,每次新加入Node都会在队头进行,通过CAS改变第一个结点的指针为新增结点(新线程),同时设置新增节点的next指向后续节点,而取数据则发生在队尾。通过这种方式减轻了队列取数据时的争用问题。而且该结构是个Lock-Free的队列无锁队列(实际上就是通过CAS不断的尝试来实现的)。

  • EntryList:CXQ队列中有资格成为候选资源的线程会被移动到该队列中。

    • 获得锁得到执行权力的Owner线程在释放锁时会从CXQ队列或EntryList中挑选一个线程唤醒,到底唤醒哪个取决于Monitor的策略:
      • 可以直接绕过EntryList直接将线程放到OnDeck中
      • 将线程插入到EntryList尾部
      • 将线程插入到EntryList头部
    • 指定EntryList中的某个线程(一般为Head)为Ready(OnDeck)线程,该线程叫做假定继承人。但是并不会把锁传递给OnDeck先出,只是把竞争锁的权利交给OnDeck(sync是非公平的,不一定该线程就能获取到锁)。OnDeck先出需要重新竞争锁。这样做虽然牺牲了一定的公平性,但极大的提高了整体吞吐量,在 Hotspot中把OnDeck的选择行为称之为“竞争切换”。这里存储的线程对应Java线程状态的Blocked状态。
  • OnDeck:任何时刻最多只能有一个线程正在竞争锁,该线程称为OnDeck。

  • **Owner:获得锁的线程称为Owner。**初始时为NULL,当有线程占有该monitor的锁的时候,Owner标记为该线程的唯一标识,当线程释放monitor时,Owner又恢复为NULL。无法获得锁则会依然留在EntryList中,在EntryList中的位置不发生变化。

  • **WaitSet:如果Owner线程被wait方法阻塞,则转移到WaitSet队列。**当wait线程在某个时刻被notify/notifyAll之后,会将对应的ObjectWaiter从WaitSet移动到EntryList或CXQ队列中。如何移动取决于Monitor的策略:

    • 可能将WaitSet队列中的对象插入EntryList队列的头or尾部
    • 可能将WaitSet队列中的对象插入CXQ队列的头or尾部

    WaitSet存放的是处于等待状态的线程,这些线程在等待某种特定的条件变成真,所以又称为条件队列。这个Monitor的wait/notify/notifyAll方法实际上是为上层提供的操作API。所以要调用这个条件队列的方法,必须先拿到这个Monitor,相应的,对于同步方法或者同步代码块中,就会有一个推论就是“wait/notify/notifyAll方法只能出现在相应的同步块或同步方法中”。如果不在同步方法或同步块中,运行时会报IllegalMonitorStateException。

每个等待锁的线程都会被封装成ObjectWaiter对象,保存了Thread(当前线程)以及当前的状态ThreadState等数据。

volatile vs synchronized

volatilesynchronized
本质告诉jvm当前线程的工作变量可能是不正确的,需要从主内存中重新读取锁定当前共享变量,只有获取锁的线程才能访问该共享变量
使用范围变量方法,代码块
特性有序性,可见性,无法保证原子性原子性,有序性,可见性
线程阻塞不会造成线程阻塞会阻塞线程

CAS及其优化

CAS的简介
  • CAS:Compare and Swap,比较并交换。是一个虚拟机实现的原子操作,功能是将旧值替换为新值,如果旧值在替换的时候与之前相比没有改变,则替换成功,否则失败
  • CAS是一种乐观锁的策略,它假设每一次在访问共享资源时都不会产生冲突,不冲突就不会阻塞其他线程获取该锁,这样线程就会不出现阻塞停顿状态。Java使用CAS操作来鉴别线程是否出现冲突,出现就重试当前操作直到没有操作为止。线程只会收到操作失败的信号并进行原地自旋,并不会阻塞。
CAS的实现原理
  • 三个参数V,O,N
    • V:内存地址存放的实际值
    • O:旧值
    • N:即将更新的新值
    • **当旧的预期值O和内存中实际存放的值V相同,表明该值没有被其他线程更改过,此时CAS通过原子的方式把修改的值写入内存并返回true。**这是一个比较与更新的操作,该操作是原子操作。
      • 在内存地址中,存储一个值为11的变量V。
      • 此时线程1想把变量的值减少1,对于线程1来说,旧值O就是11,要更新的新值N为10。
      • 在线程1要提交更新之前,线程2抢先一步把内存地址中的变量值V率先更新为10。
      • 线程1开始提交更新,将旧值O与内存中实际存放的值V进行比较,发现不相等,提交失败。不进行操作,返回false。
    • 多个线程使用CAS操作一个变量时,只有一个线程会成功更新,其余会失败(并不会阻塞)。失败的线程会重新尝试直到成功,也可以选择将其挂起。synchronized存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS在竞争时如果失败,会进行一定的尝试,而并不是单纯的进行挂起唤醒操作,因此也叫非阻塞同步。
CAS的问题
  • ABA问题:如果内存地址存放的值由A变成了B,然后又由B变回了A,此时CAS检查的时候发现共享内存的值并没有变化依然为A,但是实际上却是发生了变化。如果基本类型问题不大,如果是引用类型就会有一些问题。

    • 解决方法:对内存地址存放的值进行版本控制,这样A到B到A就变成了A1到B2到A3了。
      • Java1.5后atomic包提供的AromicStampedReference来解决ABA问题,具体封装在compareAndSet()中。compareAndSet()首先检查当前引用和当前标志与预期引用和预期标志是否相等,如果都相等,则以原子方式将引用值和标志的值设置为给定的更新值。
  • 自旋时间过长:CAS是一种非阻塞同步,线程不会自己被挂起,而是不停的尝试而自旋,自旋时间过长就会造成CPU很大的性能消耗。解决方案:破坏for循环使其超过一定时间或者一定次数时,return退出。

    • jdk1.8提供了一个LongAdder类,使用分段CAS以及自动分段迁移的方式来大幅度提升多线程高并发执行CAS的性能。

      • public class LongAdder extends Striped64 implements Serializable 
        
        /**
         * cell数组,大小总是2的幂次方
         */
        transient volatile Cell[] cells;
        /**
         * 基本值,主要在没有争用的情况下使用,在表的初始化的时候也作为一个基础值。通过CAS更新。
         */
        transient volatile long base;
        
        • 1
        • 2
        • 3
        • 4
        • 5
        • 6
        • 7
        • 8
        • 9
        • 10
      • 如果发现并发更新的线程数量不是很多,就直接给base值进行累加。如果发现并发更新的数量过多,就开始实行分段CAS机制,系统将该变量拆分为多个变量并把这些线程分配到不同的cell数组元素中,来实施分段CAS。

      • 假设当前有80个线程进行一变量的自增操作,cell数组长度为8,则每一组都有10个线程,每一组对cell数组的其中一个元素做自增,最后cell数组8个元素的值都为10,累加得到80。这就等于80个线程对i进行了80次自增操作。

    • 自动迁移机制:

      • 随着线程增多,每个cell中分配的线程数也会增多,当其中一个线程操作失败的时候,它会自动迁移到下一个cell中进行操作,这也就解决了CAS空旋转,自旋不停等待的问题。
  • 只能保证一个共享变量的原子操作:如果对多个共享变量同时进行操作,CAS不保证其原子性。

    • 解决方法:利用对象整合多个变量,即一个类中的成员就是这几个变量,然后对这个对象进行CAS操作,这么做就能保证其原子性,Atomic提供了AtomicReference来保证引用对象的原子性;或者可以加锁。

线程池深入理解

为什么要使用线程池
  • 线程过多会带来额外的开销,例如创建销毁线程的开销以及调度线程的开销等,这同时也降低了计算机整体的性能。使用线程池维护多个线程,等待监督管理者分配可并发执行的任务,这么做一方面避免了处理任务时创建销毁线程开销的代价,另一方面避免了线程数量膨胀而导致的过分调度问题,保证了对内核的充分利用。
  • 优势:
    • 降低资源消耗:通过池化技术重复利用已创建的线程,降低线程创建和销毁造成的损耗。
    • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
    • 提高线程的可管理性:线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会因为线程的不合理分布导致资源调度失衡。使用线程池可以进行统一的分配,调优和监控。
  • 解决的问题:
    • 频繁申请/销毁资源和调度资源,将带来额外的消耗。
    • 对资源无限申请缺少抑制手段,易引发系统资源耗尽的风险。
    • 系统无法合理管理内部的资源分布,降低系统的稳定性。
线程池的设计
  • 核心类:ThreadPoolExecutor类
    • ThreadPoolExecutor-》AbstractExecutorService-》ExecutorService-》Executor
    • 顶层的Executor提供了一些思想:
      • 将任务提交和任务执行解耦,使用户无需关心如何创建线程,如何调度线程来执行任务。
      • 用户只需要提供Runnable对象,将任务的运行逻辑提交到执行器Executor中,由Executor框架完成线程的调配和任务的执行部分。
    • AbstractExecutorService提供了一些能力:
      • 扩充执行任务的能力,补充可以为一个或一批异步任务生成Future的方法
      • 提供了管控线程的方法,比如停止线程池的运行
    • AbstractExecutorService则是上层的抽象类,将执行任务的流程串联了起来,保证下层的实现只需关注一个执行任务的方法即可。
    • ThreadPoolExecutor则一方面维护自身的生命周期,另一方面同时管理线程和任务
  • 线程池的工作流程和原理:**线程池的内部实际上构建了一个生产者消费者模型,将线程和任务两者解耦,并不直接关联,从而良好的缓冲任务,复用线程。**线程池的运行主要分成两部分:任务管理、线程管理。
    • 任务管理充当生产者的角色,任务提交后,线程池会判断该任务后续的流转:
      • 直接申请执行该任务
      • 缓冲到任务队列中等待执行
      • 拒绝该任务
    • 线程管理部分则是消费者,被统一维护在线程池内,根据任务请求进行线程的分配,当线程执行完任务后则会继续获取新的任务去执行,最终当线程获取不到任务的时候就被回收。
线程池的参数
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler){...}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • corePoolSize:线程池的基本大小,即在没有任务需要执行的时候线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。在初次创建了线程池后,线程池中其实是没有任何线程的,而是等待有任务到来才创建线程去执行任务,当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到任务队列(阻塞队列)当中。核心线程在allowCoreThreadTimeout被设置为true时会超时退出,默认情况下不会退出。

  • maximumPoolSize:线程池的最大线程数量。当线程数大于或等于核心线程,且任务队列已满时,线程池会创建新的线程,直到线程数量达到maxPoolSize。如果线程数已等于maxPoolSize,且任务队列已满,则已超出线程池的处理能力,线程池会拒绝处理任务。

  • keepAliveTime:空闲线程的存活时间。**如果当前线程池的线程个数已经超过了corePoolSize且线程空闲时间超过了keepAliveTime的话,就会将这些空闲线程销毁。**这么做尽可能的降低了系统资源损耗。

  • unit:keepAliveTime的时间单位

  • workQueue:用于保存任务的阻塞队列,可以使用ArrayBlockingQueue, LinkedBlockingQueue, SynchronousQueue, PriorityBlockingQueue。

  • threadFactory:创建线程的工厂类。可以用来设定线程名、是否为daemon线程等等。

  • handler:饱和策略。当阻塞队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,说明当前线程池已经处于饱和状态了,这时候有任务提交就需要采用一种策略来处理这种情况。

    • 拒绝策略是一个接口。可以实现该接口去定制拒绝策略,也可以使用jdk提供的四种拒绝策略。

      public interface RejectedExecutionHandler {
          void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
      }
      
      • 1
      • 2
      • 3
    • AbortPolicy:中断策略,直接拒绝所提交的任务,抛出RejectedExecutionException异常。是线程池默认的拒绝策略,在任务不能再提交时抛出异常,及时反馈程序的运行状态。如果是比较关键的业务,建议使用此拒绝策略,这样的话在系统不能承受更大的并发量的时候能够及时的通过异常发现。

    • CallerRunsPolicy:调用者运行策略,只让调用者所在的线程来执行任务。只要线程池不关闭,则会在调用excute的线程时执行这个被拒绝的任务。这种情况是要让所有任务都执行完毕,适合大量计算的任务类型去执行。

    • DiscardPolicy:舍弃策略,不处理直接丢掉任务。一般建议无关紧要的业务采用此策略。

    • DiscardOldestPolicy:舍弃最旧任务策略,丢弃掉任务队列中存放时间最久的任务,执行当前任务

线程池生命周期管理
  • 线程池内部将运行状态(runState)和线程数量(workerCount)两个关键参数的维护放在了一起:

    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    
    • 1

    这一个变量维护了两个信息高3位用来保存runState,低29位则用来保护workCount,两者互不干扰。这样做的好处就是可以避免在做相关决策时出现不一致的情况,不必为了维护两者的一致去占用锁资源

  • ThreadPoolExecutor运行状态5种:

    • RUNNING:能接受新提交的任务,并且能处理阻塞队列中的任务;线程池的初始化状态是RUNNING,即线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0。
    • SHUTDOWN:关闭状态,不再接受新提交的任务,但却可以继续处理阻塞队列中已经保存的任务;调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN。
    • STOP:不接受新提交的任务也不会处理阻塞队列中的任务,会中断正在处理任务的线程;调用线程池的shutdownNow()接口时,线程池由(RUNNING 或 SHUTDOWN ) -> STOP。
    • TIDYING:所有的任务都终止了,workerCount为0。当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。 当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。
    • TERMINATED:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。
线程池中任务的调度与执行机制
  • execute方法完成的工作:检查现在线程池运行的状态、运行线程数、运行策略来决定接下来的执行流程,是直接申请线程执行或是缓冲到队列中执行,亦或是直接拒绝该任务。
  • 核心思想:使用了核心线程池corePoolSize,阻塞队列workQueue和线程池线程最大个数maximumPoolSize这样的缓存策略来处理任务。
  • execute的流程:
    • 首先检查该线程的运行状态是否是RUNNING,如果不是则直接拒绝,线程池要保证在RUNNING状态下执行任务。
    • 如果workerCount < corePoolSize,则创建并启动一个新线程来执行当前任务。
    • 如果workerCount >= corePoolSize,且线程池阻塞队列未满,则将任务添加到该阻塞队列中。
    • 如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池阻塞队列已满,则创建并启动一个线程来执行任务。
    • 如果线程个数已经超过了maximumPoolSize且线程池内阻塞队列已满,则会使用饱和策略RejectedExecutionHandler来进行处理,通常是直接抛异常。
线程池中任务的阻塞队列
  • 作用:用来存储等待执行的任务

  • 线程公平访问队列:指阻塞的线程可以按照阻塞的先后顺序访问队列,即先阻塞先访问的形式。在队列为空时,获取元素的线程会等待队列变为非空,当队列满时,存储元素的线程会等待队列可用。阻塞队列常用于生产者和消费者的场景,生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程。

  • 常见的阻塞队列:

    • ArrayBlockingQueue:用数组实现的有界阻塞队列,按照FIFO的原则对元素进行排序。支持公平锁和非公平锁。

    • LinkedBlockingQueue:用链表实现的有界阻塞队列,默认最长为Integer.MAX_VALUE。吞吐量高于ArrayBlockingQueue。按照FIFO的原则对元素进行排序,使用较多。通常在Executors.newFixedThreadPool()使用。

    • SynchronousQueue:不存储元素(无容量)的阻塞队列,每个put操作必须等待一个take操作,否则不能继续添加元素。支持公平访问队列,常用于生产者消费者模型,吞吐量高,使用较多。

      • 每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态
      • 该阻塞队列吞吐量高于LinkedBlockingQueue,在Executor.newCachedThreadPool()中使用
    • PriorityBlockingQueue:支持优先级的无阻塞队列,默认进行自然排序,两个元素优先级相同不保证顺序。

线程池的关闭
  • 通过shutdown和shutdownNow两个方法,原理都是遍历线程池中的所有线程,然后依次中断线程
    • shutdownNow首先将线程池的状态设置为STOP,然后停止所有正在执行和未执行任务的线程,并返回等待执行任务的列表。
    • shutdown是将线程池的状态设置为SHUTDOWN,然后中断所有未执行任务的线程。
    • 即shutdown就是会将现在正在执行的任务执行完毕,而shutdownNow则是会中断正在执行的任务。调用这两个任意一个,isShutdown都会返回true,而当所有线程都关闭后,isTerminated才会返回true。
合理配置线程池参数
  • 任务的性质:CPU密集型、IO密集型、混合型
    • CPU密集应该尽可能少的配置线程数量,例如CPU+1个线程数的线程池。
    • IO密集型任务则由于需要等待IO操作,线程并不是一直在执行任务,则尽可能多的配置,例如CPU*2+1个线程数的线程池,这样可以提高CPU的利用率。
    • 混合型任务看情况拆分:如果分解后两个任务执行的时间相差不是太大,分解后执行的吞吐率要高于串行执行的吞吐率则进行拆分。否则不建议拆分
    • 可以使用Runtime.getRuntime().availableProcessors()方法获得当前设备的CPU个数。
  • 任务的优先级:高、中和低。优先级不同的任务使用优先级队列PriorityBlockingQueue来处理,让优先级高的任务先执行,但如果一直有优先级高的任务执行,优先级低的任务将可能永远不会得到执行。
  • 任务的执行时间:长、中和短。交给不同规模的线程池来处理,或者是使用优先级队列PriorityBlockingQueue让执行时间短的任务先执行。
  • 任务的依赖性:是否依赖其他系统资源,例如数据库连接。如果等待数据库返回时间很长,应该设置多的线程数,这样能够更好的利用CPU。
用Executors创建四种常见的线程池
public static ExecutorService newXxxThreadPool()
  • 1
  • newSingleThreadPool:一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO、LIFO、优先级)去执行。

    • 核心线程池大小:1;最大线程池大小:1;线程过期时间:0ms;使用LinkedBlockingQueue作为任务队列,默认容量Integer.MAX_VALUE,即没有上限。
    • 执行流程:
      • 线程池中没有线程就新键一个线程来执行任务。
      • 有一个线程以后,将任务加入阻塞队列,不停的加。
      • 唯一的线程去不断地从队列中取任务执行。
    • SingleThreadPool用于串行执行任务的场景,每个任务必须按照顺序执行,不需要并发执行。
  • newFixedThreadPool:创建一个定长的线程池。每提交一个任务,则创建一个工作线程,达到最大线程数目后,进入任务队列。该线程池不会释放空闲线程,对资源有一定的占用和浪费。(可以控制最大并发数)

    • 核心线程池大小:传入参数;最大线程池大小:传入参数;线程过期时间:0ms;使用LinkedBlockingQueue作为任务队列,默认容量Integer.MAX_VALUE,即没有上限。
    • 执行流程:
      • 1.线程数少于核心线程数,即设置的线程数,就新键线程执行任务。
      • 2.线程数等于核心线程数,将任务加入阻塞队列,队列很大,一直加。
      • 3.执行完任务的线程反复去任务队列中取任务执行。
    • FixedThreadPool用于负载比较重的服务器,为了资源合理的利用,需要限制当前线程数量。
  • newCacheThreadPool:弹性缓存线程池,创建的时候线程池中没有线程,只有当通过execute()和submit()方法提交任务时,如果有空的线程则让空的线程执行该任务,否则创建新的线程来执行该任务。创建线程的最大值取决于默认最大值,如果空闲时间超过60s,则进行回收线程操作。

    • 核心线程池大小:0;最大线程池大小:Integer.MAX_VALUE;线程过期的时间是60s;使用SynchronousQueue作为任务队列。
    • 并无核心线程,全部由非核心线程来执行任务,但是每个线程空闲时间只有60s,超过就被回收。因此当提交任务的速度大于处理任务的速度时,每次提交任务就会创建一个线程,极端情况下就会创建很多线程,耗尽CPU和内存资源。
    • 执行流程:
      • 1.直接向SynchronousQueue里提交任务。
      • 2.如果有空闲线程,就取出任务执行,如果没有就新键一个。
      • 3.执行完任务的线程有60秒的生存时间,如果在这个时间内可以接到新任务,就继续存活下去,否则就死亡。
      • 4.由于空闲60s的线程就会说再见,则长时间保持空闲的CachedThreadPool不会占用任何资源。
    • CachedThreadPool适用于并发执行大量而短期的小任务,或者是负载比较轻的服务器。
  • newScheduleThreadPool:创建定长的线程池,支持定时以及周期性的任务执行。

    • 核心线程池大小:传入参数;最大线程池大小:Integer.MAX_VALUE;线程过期时间0ms;使用DelayedWorkQueue作为任务队列。
    • 执行流程:
      • 1.给DelayedWorkQueue中添加任务。
      • 2.线程池中的线程从DelayedWorkQueue中获取time >= 当前时间的ScheduledFutureTask(定时任务)。
      • 3.执行完后修改这个task的time为下次被执行的时间。
      • 4.然后把该task放入队列中。
    • ScheduledThreadPool适用于需要多个后台线程执行周期任务且限制线程数量的场景
  • 线程池提交任务:

    • execute():提交不需要返回值的任务

      void execute(Runnable command);
      //该方法无返回值,提交后无法判断该任务是否被线程池执行成功
      
      • 1
      • 2
    • submit():提交需要返回值的任务,提供了三个重载

      <T> Future<T> submit(Callable<T> task);
      <T> Future<T> submit(Runnable task, T result);
      Future<?> submit(Runnable task);
      //可以看出既支持Callable参数或者Runnable,同时返回一个Future对象,可以通过该方法来判断任务是否执行成功
      //使用Future.get()方法获取执行结果,该方法会阻塞当前线程直到任务完成
      
      • 1
      • 2
      • 3
      • 4
      • 5
  • 阿里巴巴开发手册禁止直接创建上面四个线程池:

    • FixedThreadPool和SingleThreadExecutor:堆积的请求处理队列可能会耗费大量内存,甚至OOM
    • CachedThreadPool和ScheduledThreadPool:最大线程池大小都是Integer.MAX_VALUE,可能会创建非常多的线程,耗费内存,甚至OOM。

AbstractQueuedSynchronizer

AQS的框架介绍(state)
  • AQS是一同步器,该类在java.util.cocurrent.locks包下面,其用来构建锁和其他同步组件的基础框架,例如ReentrantLock,ReentrantReadWriteLock等。它主要依赖一个int成员变量state来表示同步状态以及通过一个FIFO队列构成等待队列。其子类必须重写AQS的几个protected修饰的用来改变同步状态的方法,其他方法主要是实现了排队和阻塞机制。

  • AQS维护了一个 volatile int state(表示当前同步状态)和一个FIFO的线程等待队列(多线程争用资源被阻塞时会进入此队列)。juc就是基于AQS实现的。底层核心数据结构:双向链表+state(锁状态),底层的操作为CAS。

    • 如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。
    • 如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
    • 对于state提供了三种原子操作:
      • getState();
      • setState();
      • compareAndSetState();
    • CLH队列:虚拟的双向队列,仅仅存在结点之间的关联关系,该队列中的结点都是来竞争同一个资源的。与同步队列不同的是,同步队列里的任务都是等待唤醒的。AQS是将每一条请求共享资源的线程封装成一个CLH锁队列的一个结点来实现锁的分配。
  • AQS的设计是使用模板方法设计模式,它将一些方法开放给子类进行重写,而同步器给同步组件所提供模板方法又会重新调用被子类所重写的方法

    • AQS可重写的方法如下:

      • protected boolean isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
      • protected boolean tryAcquire(int arg):独占方式。尝试获取资源,成功则返回true,失败则返回false。
      • protected boolean tryRelease(int arg):独占方式。尝试释放资源,成功则返回true,失败则返回false。
      • protected int tryAcquireShared(int arg):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
      • protected boolean tryReleaseShared(int arg):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
    • 实现同步组件时AQS提供的模板方法如下:

      AQS提供的模板方法.png

    • 模板方法举例:

      • AQS中有一方法tryAcquire:

        protected boolean tryAcquire(int arg) {
            throw new UnsupportedOperationException();
        }
        
        • 1
        • 2
        • 3

        而ReentrantLock的内部类Sync继承了AQS,Sync的子类NonfairSync有一个方法tryAcquire重写了AQS的tryAcquire:

        protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }
        
        • 1
        • 2
        • 3

        AQS中的acquire方法又调用了该NonfairSync的方法:

        public final void acquire(int arg) {
            if (!tryAcquire(arg) &&
                acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
                selfInterrupt();
        }
        
        • 1
        • 2
        • 3
        • 4
        • 5

        此时,NonfairSync在调用模板方法acquire的时候,就会调用被自己重写的tryAcquire方法。

  • AQS定义了两种资源访问方式:

    • Exclusive:独占,只有一个线程能获得资源并执行,例如ReentrantLock
    • Share:共享,多个线程能够同时执行,例如Semaphore和CountDownLatch
  • 实现了AQS的锁有:自旋锁。互斥锁、读锁写锁。

AQS的同步队列
  • 在线程获取锁时会调用AQS的acquire方法,该方法第一次尝试获取锁如果失败,会将该线程加入到该同步队列中,加入同步队列中的线程状态为阻塞。AQS中的同步队列是通过链式方式进行实现的一个双向链表

  • AQS中有一个静态内部类Node,对每一个等待获取资源线程的封装,里面包含了需要同步的线程本身以及其等待状态,例如是否被阻塞、是否等待唤醒、是否已经被取消等待。

    static final Node SHARED = new Node(); //指示结点正在共享模式下等待的标记
    static final Node EXCLUSIVE = null; //指示结点正在独占模式下等待的标记
    volatile int waitStatus;  //结点状态
    volatile Node prev; //当前结点/线程的前驱节点
    volatile Node next; //当前结点/线程的后继节点
    volatile Thread thread; //加入同步队列的线程引用
    Node nextWaiter; //等待队列中的下一个节点
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
  • 结点的状态有这些:

    static final int CANCELLED =  1 //表示当前结点已经取消调度。当timeout或响应中断,会出发变更为此状态,进入该状态后的结点将不会再变化
    static final int SIGNAL = -1    //表示当前结点后继的线程处于等待状态,如果当前结点释放同步状态会通知其后继,使得后继节点的线程能够运行
    static final int CONDITION = -2 //表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待状态转移到同步队列中,等待获取同步锁
    static final int PROPAGATE = -3 //表示在共享模式下,前驱结点不仅会唤醒后继结点,同时也可能唤醒后继的后继结点
    static final int INITIAL = 0;   //新结点入队的默认状态
    
    • 1
    • 2
    • 3
    • 4
    • 5

    负值表示有效的等待状态,正值表示结点以及被取消。所以可用waitStatus是否大于等于0来判断结点的状态是否正常。

AQS中的Condition
  • jdk1.5后出现,替代Object中的wait()、notify()、notifyAll()方法来实现线程间的通信,使线程协作更加安全和高效。其就是一个条件队列,区分与前面说的等待队列。条件队列是由Condition形成的条件队列,线程被await操作挂起后就会被放入条件队列,这个队列中的节点都被挂起,他们都等待被signal后进入阻塞队列再次获取锁

  • Condition为一个接口:

    public interface Condition {
        void await() throws InterruptedException;
        void awaitUninterruptibly();
        long awaitNanos(long nanosTimeout) throws InterruptedException;
        boolean await(long time, TimeUnit unit) throws InterruptedException;
        boolean awaitUntil(Date deadline) throws InterruptedException;
        void signal();
        void signalAll();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
  • Condition中有两个变量firstWaiter和lastWaiter,类似于头结点和尾结点。

  • Condition中的node有两个状态:

    • private static final int REINTERRUPT = 1; 标志线程需要再次执行中断操作
    • private static final int THROW_IE = -1; 标志线程直接抛出中断异常
  • 当运行中的线程被await操作后就会被加入条件队列:

    • 首先判断当前的线程是否被interrupt而死亡,如果是则直接抛出异常。
    • 调用addConditionWaiter方法新建一个结点并添加到condition队列中。
    • 调用fullyRelase释放锁,并且唤醒等待队列中的首结点
    • 通过isOnSyncQueue判断该结点是否已经在AQS队列中。
      • 如果不在的话,调用LockSupport.park去阻塞当前线程。
      • 如果在的话,表明当前线程在AQS队列中,需要让其自旋等待到获取锁。然后将该node的状态标识为需要再次执行中断(因为还没有加入到condition队列中)
        • 这种情况一般是当前结点被移动到了AQS中。例如其他的线程调用了condition的signal或者signalAll方法
    • 最后处理线程被中断的情况
  • 当调用condition的signal或者signalAll方法时,会将等待队列中的结点移动到同步队列中,使该结点有机会获得lock。

    • signal方法是从等待队列头开始唤醒的。
      • 首先检测当前线程是否已经获取了lock,如果没有的话直接抛出异常。
      • 然后获取等待队列中的第一个结点,之后的所有操作都是针对这个结点。
        • 将头结点从等待队列中移除,并更新其状态为0,然后将其加入到同步队列中。
    • 而signalAll方法则是将等待队列中所有的结点加入到同步队列中

ReentrantLock

ReentrantLock:重入锁,是实现Lock接口的一个实现类,支持重入性,表示能够对共享资源重复加锁,即当前线程获取该锁后,再次获取不被阻塞。**ReentrantLock还支持公平锁和非公平锁。**ps:synchronized关键字隐式支持重入性,通过获取自增,释放自减来实现重入。

重入性要解决的问题
  • 线程获取锁的时候,已经成功获取锁的线程是当前线程则直接获取成功,无须阻塞。
  • 锁会被获取N次,那么只有锁被释放N次后,该锁才完全被释放成功。
  • 实现方式:AQS维护了一个变量private volatile int state; 来记录重入次数。在ReentrantLock子类中的final boolean nonfairTryAcquire(int acquires), 当线程尝试获取锁时,可重入锁先尝试获取并更新state值,如果state0表示没有其他线程在执行同步代码,则把state置为1。当线程开始执行,如果state!=0则判断该线程是否是获取到锁的线程,如果是的话就执行state+1,且当前线程继续获取锁。释放的时候,可重入锁在当前线程是持有锁的线程的前提下先获取当前state的值,如果state-10则表示当前线程所有重复获取锁的行为都已经执行完毕且完全释放,然后该线程真正释放锁。
非公平锁与公平锁
  • 非公平锁:static final class NonfairSync extends Sync

    • 获取:
      • 首先获取当前线程,并获取当前同步状态。
      • 如果锁未被任何线程占有,表明该锁能被当前线程获取。设置当前线程的状态以及标识锁的持有线程为当前线程。
      • 如果锁被占有则检查该锁的持有线程是否为当前线程,如果是的话给同步状态+1,表示重入,返回true。
      • 否则获取失败返回false。
    • 释放:
      • 先给当前锁同步状态减去参数
      • 如果判断当前的线程不是锁持有的线程则直接抛出异常
      • 然后进行判断,只有同步状态减为0才完全的释放了锁
      • 更新同步状态,并返回同步状态是否减为0
  • 公平锁:static final class FairSync extends Sync

  • 与非公平锁的获取释放差不多,只不过判断了一下当前线程是否在同步队列的最前面。

  • 如果一个锁是公平的,那么锁的获取顺序就应该是符合请求的绝对时间顺序,类似排队,满足FIFO。公平锁每次都是从同步队列的第一个结点获取到锁,而非公平锁则是有可能刚释放的线程能再次获取到锁。

    • 公平锁保证请求资源时间上的绝对顺序,而非公平锁有可能导致一些线程永远无法获取到锁,导致线程饿死。
    • 公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换。而非公平锁会降低一定的上下文切换,降低性能开销。所以ReentrantLock默认是非公平锁,保证了系统更大的吞吐量。
  • 其中ReentrantLock默认的无参构造就是非公平锁:

    public ReentrantLock() {
        sync = new NonfairSync();
    }
    
    • 1
    • 2
    • 3

    还有一个带boolean类型的有参构造,true为公平,false为非公平:

    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
    
    • 1
    • 2
    • 3
ReentrantLock vs synchronized
  • 相似点:都是加锁方式同步,而且都是阻塞式的同步。
  • 区别:
    • synchronized是Java中的关键字,是原生语法层面的互斥,需要jvm实现;而ReentrantLock则是jdk1.5之后提供的API层面的互斥锁,通过Java代码实现,需要lock()和unlock()方法配合try/finally语句块来完成。
    • synchronized使用起来比较方便,由编译器去保证锁的加锁和释放;而ReentrantLock则需要手工声明来加锁和释放锁,为了避免忘记手工释放锁造成的死锁,最好在finally中声明释放锁。
    • synchronized优化以前,性能比ReentrantLock差很多,优化以后就差不多了。
    • ReentrantLock提供了synchronized没有的一些高级功能:
      • 等待可中断:持有锁的线程长期不释放的时候,正在等待的线程可以选择放弃等待。对于synchronized来说避免出现死锁的情况。通过lock.lockInterruptibly()来实现这个机制。
      • 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁。ReentrantLock默认的构造函数是非公平锁,可以通过true来设置成创建非公平锁。
      • 锁绑定多个条件,一个ReentrantLock对象可以同时绑定多个对象。ReentrantLock提供了一个Condition队列用来操作需要等待唤醒的线程们,而不是像synchronized要么唤醒一个要么全部唤醒。

各种锁整理

乐观锁vs悲观锁
  • 乐观锁认为自己在使用数据的时不会有别的线程来修改数据,所以不会添加锁。只是在更新数据的时候会判断这个数据有没有被别的线程更新。如果没有被更新,则该线程将自己修改的数据成功写入,如果数据被其他的线程更新,则根据不同的实现方式去执行不同操作(报错或者重试)。乐观锁通过无锁编程实现,最常见的就是CAS操作,Java原子类的递增就是通过CAS自旋实现的。
  • synchronized和lock都是悲观锁。悲观锁认为自己在使用数据的时候一定会有别的线程来修改数据,因此再获取数据的时候先加锁,确保数据不会被别的线程修改。
自旋锁vs适应性自旋锁
  • 自旋锁:阻塞或者唤醒一个Java线程需要操作系统切换CPU状态来完成,这种转换状态十分耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间可能比同步代码快中的代码执行时间还长。有个策略就是当有线程执行的时候,后面请求锁的线程不放弃CPU执行时间,而是就进行自旋,当前面的释放锁时后面的线程可以直接获取同步资源,从而避免了切换线程的开销。自旋锁本身是有缺点的,自旋不能代替阻塞。自旋虽然避免了线程切换的开销,但是要占用处理器时间,如果锁被占用的时间很短,自旋等待就很好,反之锁被占用的时间很长,线程不停的自旋,就会白白浪费处理器资源。自旋的次数默认是10次,可以通过-XX:PreBlockSpin来修改。如果自旋次数超过了默认次数还没有获得锁,就应该挂起线程。自旋锁的实现也是基于CAS的。
  • 适应性自旋锁:自旋时间不再固定,是由前一次在同一个锁上的自旋时间已经锁的拥有者的状态来决定。如果在同一个锁上一线程自旋等待刚刚成功获得锁,且持有锁的线程正在运行中,那么虚拟机认为该线程的这次自旋也是很有可能再次成功,进而允许它自旋等待持续相对更长的时间;反之如果对于某个锁,自旋就很少成功,那么以后在尝试获取该锁的时候就可能省略自旋,直接阻塞避免浪费处理器资源。
无锁vs偏向锁vs轻量级锁vs重量级锁

详情看前面的synchronized

公平锁vs非公平锁

详情看前面的ReentrantLock

可重入锁vs不可重入锁
  • 可重入锁:又名递归锁,指在同一线程在外层方法outer()获取锁的时候,再进入该方法的内层的加锁inner()方法时会自动获取锁(前提是锁对象是同一个对象或者class),不会因为之前已经释放过还没释放而阻塞。Java中的ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点就是一定程度上避免了死锁。
  • 不可重入锁:刚才的例子,该线程获得锁,执行outer()方法后欲执行inner()之前就需要将执行outer()时的获得的锁释放掉,实际上该对象锁已经被当前线程所持有,且无法释放,所以出现死锁。
    • 举例说明:
      • 在一个地方打水的时候,我们允许一个人提一个桶,不过这个桶可以绑多个桶,则一个人打完一个,再打剩下的桶,然后告诉管理员打完了走人,这就是可重入锁。
      • 而在另一个地方如法炮制,也用一个桶绑多个,但是实际打的时候管理员规定一个桶只能打一桶水,打完后我们第二个桶无法打水,水没打够我们走不了,出现死锁,整个等待队列都无法被唤醒。
独享锁vs共享锁
  • 独享锁:又名排他锁、互斥锁。指该锁一次只能被一个线程所持有,如果线程A对数据加上互斥锁后,其他线程不能对该数据加任何类型的锁,获得互斥锁的线程A既能读数据也能修改数据。
  • 共享锁:该锁可以被多个线程锁持有,如果线程A对数据加上共享锁后,则其他线程只能对改数据再加共享锁。获得共享锁的线程只能读数据,不能写数据。
  • 读写锁ReentrantReadWriteLock:两把锁ReadLock和WriteLock,都是基于内部类Sync实现的。读锁是共享锁,而写锁是互斥锁。读锁的共享锁保证了并发读十分高效,而读写、写读、写写的过程互斥,因为读写锁是分离的,因此ReentrantReadWriteLock的并发性比一般的互斥锁有了很大提升。
  • 具体实现:AQS一状态state,用来记录同步状态,互斥锁就是01(前面提到的重入锁就是重入的次数),在共享锁中state就是持有锁的数量。但是ReentrantReadWriteLock有读写两把锁,所以在state这一整形变量(int类型 32位)上分别描述读锁和写锁的数量(状态)。于是state被分为两个部分:高十六位为读锁状态(个数);低十六位为写锁状态(重入次数)。
    • 写锁的获取:
      • 首先获取到当前线程current,锁的个数c,以及写锁的重入次数w(通过低十六位和锁个数c做与运算)
      • 判断锁的个数是否为0,不是0的话:
        • 如果写锁的个数为0(表示存在读锁),表示存在读锁或者持有锁的线程不是当前线程(非公平锁的插队),返回false。
        • 如果当前线程获取写锁后,锁重入的数量大于最大数2^16-1,抛出Error
        • 设置当前锁的个数
      • 如果当前线程是否要被阻塞或者通过CAS增加写锁数失败,返回false
      • 如果c=0,w=0或者c>0,w>0(重入),则设置当前线程为锁的拥有者。
    • 写锁的tryAcquire()除了重入条件(当前线程为了获取写锁的线程)之外,增加了一个读锁是否存在的判断。如果存在读锁,写锁就不能被获取。原因是:必须确保写锁的操作对读锁可见。因为如果允许读锁在已经被获取的情况下对写锁的获取,则正在运行的其他线程是感受不到当前线程的写操作的。也就是说,读锁的过程中,不能让其他线程获取写锁。因此,只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦获取,其他读写线程的后续访问全部被阻塞。
    • 读锁的获取:
      • 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态-1。
      • 如果当前线程获取了写锁或者写锁未被获取,则当前线程增加读状态,成功获取读锁。获取是通过CAS来获取的。读锁的释放均是减少读状态,减少是1<<16,因为读锁是高16位。
      • 读写锁的锁降级:写锁能够降级为读锁,顺序为获取写锁、获取读锁、释放写锁。

CountDownLatch与CyclicBarrier

倒计时器CountDownLatch

多线程协作完成业务功能时,有时候需要等待其他多个线程完成任务之后,主线程才能继续往下执行业务功能,通常可以使用Thread的join方法,让主线程等待被join的线程执行完之后,才继续往下执行。CountDownLatch就是一个类似于倒计时的工具类,可以方便的完成这种场景。

其构造方法为:public CountDownLatch(int count)。构造方法会传入一个整型数N,之后调用CountDownLatch的countDown方法会对N减一,直到N减到0的时候,当前调用await方法的线程继续执行。

  • await() throws InterruptedException:调用该方法的线程等到构造方法传入的N减到0的时候,才能继续往下执行
  • await(long timeout, TimeUnit unit):与上面的await方法功能一致,只不过这里有了时间限制,调用该方法的线程等到指定的timeout时间后,不管N是否减至为0,都会继续往下执行
  • countDown():使CountDownLatch初始值N减1
  • long getCount():获取当前CountDownLatch维护的值

一个很通俗的例子,运动员进行跑步比赛时,假设有6个运动员参与比赛,裁判员在终点会为这6个运动员分别计时,可以想象每当一个运动员到达终点的时候,对于裁判员来说就少了一个计时任务。直到所有运动员都到达终点了,裁判员的任务也才完成。这6个运动员可以类比成6个线程,当线程调用CountDownLatch.countDown方法时就会对计数器的值减一,直到计数器的值为0的时候,裁判员(调用await方法的线程)才能继续往下执行。

循环栅栏CyclicBarrier

CyclicBarrier也是一种多线程并发控制的实用工具,和CountDownLatch一样具有等待计数的功能,但是相比于CountDownLatch功能更加强大。CyclicBarrier在使用一次后,下面依然有效,可以继续当做计数器使用,这是与CountDownLatch的区别之一

  • await():阻塞当前线程,等到所有的线程都到达指定的临界点才继续往下执行。
  • await(long timeout, TimeUnit unit):与上面的await方法功能基本一致,只不过这里有超时限制,阻塞等待直至到达超时时间为止
  • int getNumberWaiting():获取当前有多小个线程阻塞等待在临界点上
  • boolean isBroken():用于查询阻塞等待的线程是否被中断
  • void reset():将屏障重置为初始状态。如果当前线程正在临界点上,抛出BrokenBarrierException

CyclicBarrier提供了一个构造方法:public CyclicBarrier(int parties, Runnable barrierAction),意思是当指定的线程都到达了指定的临界点的时,接下来执行的操作可以由barrierAction传入即可。

CountDownLatch vs CyclicBarrier
  • CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行。强调一个线程等多个线程完成某件事情;而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行。强调的是一个互等的关系。
  • 调用CountDownLatch的countDown方法后,当前线程并不会阻塞,会继续往下执行;而调用CyclicBarrier的await方法,会阻塞当前线程,直到CyclicBarrier指定的线程全部都到达了指定点的时候,才能继续往下执行;
  • CountDownLatch方法比较少,操作比较简单,而CyclicBarrier提供的方法更多,比如能够通过getNumberWaiting(),isBroken()这些方法获取当前多个线程的状态,并且CyclicBarrier的构造方法可以传入barrierAction,指定当所有线程都到达时执行的业务功能;
  • CountDownLatch是不能复用的,而CyclicLatch是可以复用的。

Semaphore和Exchanger

Semaphore

信号量Semaphore,主要是用于控制资源能够被并发访问的线程数量,以保证多个线程能够合理的使用特定资源。Semaphore就相当于一个许可证,线程需要先通过acquire方法获取该许可证,该线程才能继续往下执行,否则只能在该方法出阻塞等待。当执行完业务功能后,需要通过release()方法将许可证归还,以便其他线程能够获得许可证继续执行。

Semaphore可以用于做流量控制,特别是公共资源有限的应用场景,比如数据库连接。假如有多个线程读取数据后,需要将数据保存在数据库中,而可用的最大数据库连接只有10个,这时候就需要使用Semaphore来控制能够并发访问到数据库连接资源的线程个数最多只有10个。在限制资源使用的应用场景下,Semaphore是特别合适的。

Semaphore的构造方法中还支持指定是否具有公平性,默认非公平。

Exchanger

线程间交换数据的一个工具类,提供了一个交换的同步点,在这同步点两个线程能够交换数据,使用exchange方法来实现,如果一个线程先执行exchange方法,那么它会同步等待拎一个线程也执行exchange方法,这时候两个线程就都达到了同步点,可以交换数据。

其提供了一个无参构造方法,还有两个主要方法:

  • V exchange(V x):当一个线程执行该方法的时候,会等待另一个线程也执行该方法,因此两个线程就都达到了同步点。将数据交换给另一个线程,同时返回获取的数据。
  • V exchange(V x, long timeout, TimeUnit unit):同上一个方法功能基本一样,只不过这个方法同步等待的时候,增加了超时时间。

理解:下课期间,男生经常会给走廊里为自己喜欢的女孩子送情书,相信大家都做过这样的事情吧。男孩会先到女孩教室门口,然后等女孩出来,教室那里就是一个同步点,然后彼此交换信物,也就是彼此交换了数据。

ThreadLocal体系

ThreadLocal是什么
  • ThreadLocal是java.lang包下的线程变量,该变量属于当前线程,对于其他线程而言是隔离的。ThreadLocal为变量在每个线程中都创建了一个副本,每个线程去访问自己内部的副本变量。

  • 多线程中,我们解决共享资源的安全问题时一般就是synchronized、lock,这样控制会让未获取到锁的线程进行阻塞等待,时间效率不好。线程安全问题的核心就是多个线程会对同一个临界区共享资源进行操作。TreadLocal就是让每个线程使用自己的共享资源,不会影响其他线程,让多个线程达到隔离状态,是一种空间换时间的方案。

  • 什么时候用:数据库连接管理类,使用TreadLocal给每个线程的连接创建副本,在线程内部的任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题。

  • set:

    • 获取当前线程实例
    • 通过当前线程实例获取ThreadLocalMap对象
    • 如果map不空则将当前ThreadLocal实例作为key,方法参数作为value存入。
    • 如果map为空则创建ThreadLocalMap并存入value
  • get:

    • 获取当前线程实例,并通过该实例去获取当前线程的ThreadLocalMap
    • 如果该map不空,则取出以当前ThreadLocal对象为key的键值对
    • 键值对不空,返回value
    • 如果map为空或者键值对为空,则进行初始化(根据情况判断是新建map还是存入键值对),将当前线程的实例存入ThreadLocalMap。
  • remove:

    • 获取当前线程的TreadLocalMap
    • 从map中删除以当前threadLocal实例为key的键值对
ThreadLocalMap
  • ThreadLocal的数据基本都放在了threadLocalMap中,get、set、remove都是通过操作threadLocalMap来实现的
  • ThreadLocalMap是ThreadLocal的一个静态内部类,内部维护了一个Entry类型的数组:private Entry[] table; 必要时会进行扩容,始终为二的幂。
  • 其中,该Entry是弱引用,因为Entry继承了WeakReference,在Entry的构造方法中,调用了super(k),将threadLocal实例包装成了一个WeakReference,每个线程实例中可以通过threadLocals获取到threadLocalMap,而threadLocalMap实际上就是一个以threadLocal实例为key,Object为value的Entry数组。当我们为threadLocal变量赋值,实际上就是以当前threadLocal实例为key,值为value存入threadLocalMap中。
  • ThreadLocalMap也是通过hash表来实现的,而且我们知道理想状态下hash函数可以将关键字均匀的分散到数组的不同位置,不会出现两个关键字hash值相同,但是实际上是肯定会出现hash冲突。一般解决有两个方法:分离链表法和开放定址法。
    • 分离链表法(拉链法):使用链表解决冲突,将散列值相同的元素都保存到一个链表中。当查询时的时候,首先找到元素所在的链表,然后遍历链表去朝招对于的元素。例如HashMap、ConcurrentHashMap
    • 开放定址法:不会创建链表,当关键字经过hahs函数的计算到的数组单元已经存在元素时,就会尝试在数组中寻找其他的单元。
    • ThreadLocalMap是采用开放地址法来处理hash冲突,因为ThreadLocalMap中hash值的分布十分均匀,甚少出现冲突。且ThreadLocalMap经常要清除无用对象,使用纯数组更方便
  • ThreadLocal被设计出来不是为了解决对象的多线程访问问题的,数据实质上是放在每个thread实例引用的threadLocalMap,每个不同的线程都拥有专属与自己的threadLocalMap,彼此不影响。因此ThreadLocal只适用于共享对象会造成线程安全问题的场景。

面试题:为什么是ThreadLocal和Entry的key是弱引用?

  • 前面的介绍可以知道,内存泄露的一大原因就是弱引用。但我们如果使用强引用,在业务代码中执行threadLocalInstance == null时,以清理掉threadLocal为目的,但threadLocalMap的Entry强引用threadLocal,因此gc可达性分析,threadLocal依然可达,对threadLocal并不会垃圾回收。所以弱引用尽管会出现内存泄露,但在threadLocal的生命周期里,会针对key为null的脏entry进行处理。
ThreadLocal内存泄露

img

  • 原因:由于ThreadLocal实例与Entry中key存在的引用是弱引用,当threadLocal外部的强引用(ThreadLocal Instance == null)的话,threadLocal实例就没有一条引用链路可达。在gc的时候势必会被回收,这就会存在key为null,无法通过一个key为null去访问到该entry的value。同时也存在了一条强引用链:

    threadLocal ref -》 currentThread ref -》 currentThread -》 threadLocalMap -Entry -》 value -》 memory
    
    • 1

    导致垃圾回收的时候,key为null的entry是无法被回收的,且该entry永远无法被访问到,造成内存泄露。如果线程结束后,threadLocal、threadRef、thread等就会断掉,上述的threadLocalMap、entry也会被回收。不过实际使用中我们都是会用线程池去维护线程,例如Executors.newFixedThreadPool()时,为了复用线程是不会结束的。

  • ThreadLocal做的改进:

    • 针对key为null的entry,源码注释为staleEntry,意思就是不新鲜的entry,即脏entry。在set方法中,针对脏entry做了如下处理(探测式清理):
      • 如果当前的table[i] != null说明hash冲突,就向后环形查找,如果遇到脏entry直接通过replaceStaleEntry进行处理。
      • 如果当前table[i] == null 说明新的entry直接可以插入,插入后调用cleanSomeSlots检测并清除。
        • 清除当前脏entry,即将其value引用置为null,将table[staleSlot]也置为null。value置为null使得该value域变得不可达,在下一次gc时会被回收。table[staleSlot]置为null以便于存放新的entry。
        • 从当前的staleSlot位置向后环形继续搜索nextIndex(i, len),直到遇到hash桶为null退出,在搜索过程中如果遇到了entry,继续将其清除。
          • **为什么是遇到hash桶为null退出呢?**答:因为我们判断一个entry是否脏entry是通过其table[i]不为空且key为null,如果遇到了hash桶为null,则其成为脏entry的条件都不具备。
        • 清除完后进行rehash
  • Thread.exit()会令threadLocal = null,这意味着gc的时候可以对threadLocalMap进行垃圾回收,即threadLocal的生命周期与thread的生命周期相同。每次使用完ThreadLocal,都应该调用其remove方法清除数据。在使用线程池的情况下,必须及时清理ThreadLocal,不仅是内存泄露的问题,更严重的是可能导致业务逻辑出现问题。

进程与线程的区别

根本区别资源开销包含关系内存分配影响关系执行过程
进程操作系统资源分配的基本单位每个进程都有独立的代码和数据空间,进程之间切换会有较大的开销一个进程内有多个线程,进程的执行过程是多个线程共同完成的进程之间的地址空间和资源是相互独立的一个进程崩溃后,在保护模式下不会对其他进程造成影响每个独立的进程有程序运行的入口、顺序执行序列和程序出口
线程处理器任务调度和执行的基本单位同一类线程共享代码和数据空间,每个线程都有自己独立运行的栈和程序技术器,线程之间切换开销小进程的一部分同一进程的线程共享本进程的地址空间和资源一个线程崩溃后整个进程都会死掉线程不能独立执行,必须依赖于进程,由进程提供多个线程的执行控制

Java线程模型(Java线程与操作系统线程的关系)

在Java中,平时所说的并发编程、多线程其实都是用户线程,而对应到操作系统,还有另外一种线程叫做内核线程。用户线程和内核线程之间必然存在某种关系,这种对应关系最常见有三种方法:多对一模型、一对一模型和多对多模型。

多对一

多对一又叫用户级线程模型,即多个用户线程对应到同一个内核线程上,线程的创建调度同步等所有细节全部由进程的用户控件线程库来处理。

thread model

优点:用户线程很多操作对于内核线程来说都是透明的,不需要用户态和内核态频繁的切换,使线程的创建、调度、同步等非常快。

缺点:

  • 由于多个用户线程对应到一个内核线程,如果其中一个用户线程阻塞,那么其他的用户线程也无法执行。
  • 内核并不知道用户态有哪些线程,无法像内核线程一样实现较完整的调度、优先级等。
一对一

一对一模型,又叫做内核级线程模型,即一个用户线程对应一个内核线程,内核负责每个线程的调度,可以调度到其他处理器上面。

thread model

优点:实现简单

缺点:

  • 对用户线程的大部分操作都会映射到内核线程上,引起用户态和内核态的频繁切换
  • 内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响。

Java使用的就是一对一线程模型。

多对多

多对多模型,又叫作两级线程模型,它是博采众长之后的产物,充分吸收前两种线程模型的优点且尽量规避它们的缺点。在此模型下,用户线程与内核线程是多对多(m : n,通常m>=n)的映射模型。

thread model

首先,区别于多对一模型,多对多模型中的一个进程可以与多个内核线程关联,于是进程内的多个用户线程可以绑定不同的内核线程,这点和一对一模型相似;其次,又区别于一对一模型,它的进程里的所有用户线程并不与内核线程一一绑定,而是可以动态绑定内核线程, 当某个内核线程因为其绑定的用户线程的阻塞操作被内核调度让出CPU时,其关联的进程中其余用户线程可以重新与其他内核线程绑定运行。

优点:

  • 兼具多对一模型的轻量;
  • 由于对应了多个内核线程,则一个用户线程阻塞时,其他用户线程仍然可以执行;
  • 由于对应了多个内核线程,则可以实现较完整的调度、优先级等;

缺点:实现复杂

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/凡人多烦事01/article/detail/523898
推荐阅读
相关标签
  

闽ICP备14008679号