一天搞定Java并发编程

Java并发编程

一、并发编程的挑战

1.1 如何减少上下文切换

  • 无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据

  • CAS算法:Java的Atomic包使用的CAS算法来更新数据,而不需要加锁

  • 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态

  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

1.2 避免死锁的几个常见方法

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制
  • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况

二、Java并发机制的底层实现原理

2.1 volatile的应用

2.1.1 volatile实现可见性

  • 深入说明:通过加入内存屏障和禁止重排序优化来实现
    • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令
    • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令
  • 通俗说明:volatile变量在每次被线程访问时,都强迫从主内存中重读改变量的值;而当该变量发生变化时,有会强迫线程将最新的值刷新到主内存。这样任何时刻,不同的线程总能看到改变量的最新值

2.1.2 volatile不能实现原子性

2.1.3 volatile使用场合

  • 对变量的写入操作不依赖其当前值
    • 不满足:number++、count = count*5等
    • 满足:boolean变量、记录温度变化的变量等
  • 该变量没有包含在具有其他变量的不变式中
    • 不满足:不变式low < up

2.2 synchronized的实现原理与应用

2.2.1 synchronized和对象头

​ synchronized关键字锁住的是对象,而不是代码块,具体锁住的是对象的对象头。对象的对象头中有3个bit来表是对象被锁的状态:

  • 001:无锁
  • 101:偏向锁
  • 010:轻量锁
  • 010:重量锁
  • 011:GC

2.2.2 synchronized的可见性

JMM关于synchronized的两条规定:

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中;
  • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值

2.2.3 锁的升级与对比

​ 在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量锁和重量锁。

这几个状态会随着竞争情况逐渐升级,锁可以升级但不能降级,意味着偏向锁升级成轻量锁后不能降级成偏向锁。

这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

2.2.3.1 偏向锁
  • 偏向锁的初始化:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁们只需简单地测试一下对象头的Mark Word里是否存储这只想当前线程的偏向锁。如果测试成功,表示已经获得了锁;如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1:如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁只想当前线程。

  • 偏向锁的撤销:偏向锁使用了一种等到竞争者出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

  • 关闭偏向锁:偏向锁在Java中是默认启动的,但是但是它在应用程序启动几秒钟之后才激活,如有必要,可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果确定应用程序所有的锁通常情况处于竞争状态,可以通过JVM参数关闭偏向锁:-XX-UseBiasedLocking=false,那么程序默认会进入轻量锁状态。

2.2.3.2 轻量锁
  • 轻量锁加锁:线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象中的Mark Word复制到锁记录中,官方成为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为只想锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自选来获取锁。
  • 轻量锁解锁:会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。因为自旋会消耗CPU,为了避免无用的自旋,一旦升级为重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其它线程视图获取锁时,都会被阻塞,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
2.2.3.3 锁的优缺点对比
优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁的竞争,会带来额外的锁撤销的消耗 只有一个线程访问同步块的场景
轻量锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重量锁 线程竞争不适用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行时间较长

2.2.4 synchronized的用法

  1. 锁住某个对象;
  2. 锁住this关键字,相当于锁住当前类的对象;
  3. 锁住方法,同样是锁住当前类的对象,只有获取到当前类对象之后才能执行此方法
  4. 锁住静态方法,锁住的是类,因为静态方法是由类直接调用的;
  5. 锁住静态方法里面的class对象,同样是锁住类
package com.demo.study.concurrent.synchronize.demo01;

/**
 * 

Descriptions... * * @author Johnson * @date 2019/10/3. */ public class Demo { private int count = 10; private static int sCount = 10; private Object object = new Object(); //锁住object对象 public void test1(){ synchronized (object){ count--; System.out.println(Thread.currentThread().getName()); System.out.println("count:"+count); } } //锁住当前对象,也就是想要执行这个方法,必须先拿到Demo对象的锁 public void test2(){ synchronized (this){ count--; System.out.println(Thread.currentThread().getName()); System.out.println("count:"+count); } } //test2的变形,特别注意这里锁住的是Demo的对象,而不是test3方法 public synchronized void test3(){ count--; System.out.println(Thread.currentThread().getName()); System.out.println("count:"+count); } //这里的区别是,锁住的不是Demo对象,而是Demo这个类 public synchronized static void test4(){ sCount--; System.out.println(Thread.currentThread().getName()); System.out.println("count:"+sCount); } //test4的变形 public static void test5(){ synchronized (Demo.class){ sCount--; System.out.println(Thread.currentThread().getName()); System.out.println("count:"+sCount); } } }

2.3 原子操作的实现原理

Java内存模型(JMM)

Java内存模型(Java Memory Model)描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。

  • 所有的变量都存储在主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本

两条规定:

  • 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存够中读写
  • 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成

重排序

代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化

无论如何重排序,程序执行的结果应该与代码顺序执行的结果一致(Java编译器、运行时和处理器都会保证Java在单线程下遵循as-if-serial语义)

重排序不会给单线程带来内存可见性的问题,多线程中程序交错执行时,重排序可能会造成内存可见性的问题

  • 编译器优化的重排序(编译器优化)
  • 指令级并行重排序(处理器优化)
  • 内存系统的重排序(处理器优化)

可见性分析

不可见的原因 synchronized解决方案 volatile解决方案
线程的交叉执行 原子性 不支持
重排序结合线程交叉执行 原子性 不支持
共享变量未及时更新 可见性 可见性

synchronized和volatile比较

  • volatile不需要加锁,比synchronized更轻量级,不会阻塞线程;
  • 从内存可见性角度讲,volatile读相当于加锁,volatile写相当于解锁;
  • synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性

四、Java并发编程基础

4.1 线程简介

4.1.1 什么是线程

​ 现代操作系统在运行一个程序时,会为其创建一个进程。现在操作系统调度的最小单元是线程,也叫轻量级进程(Light Weight Process),在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些线程在同时执行。

4.1.2 为什么使用多线程

  • 更多的处理核心

  • 更快的响应时间

  • 更好的编程模型

4.1.3 线程优先级

​ 现代操作系统基本采用时分的形式调度运行的线程,操作系统会分出一个个时间片,线程会分配到若干时间片,当线程的时间片用完了就会发生线程调度,并等待着下次分配。线程分配到的时间片多少也就决定了线程使用处理器资源的多少,而线程优先级就是决定线程需要多或者少分配一些处理器资源的线程属性

​ 在Java线程中,通过一个整型成员变量priority来控制优先级,优先级的范围从1~10,在线程构建的时候可以通过setPriority(int)方法来修改优先级,默认优先级是5,优先级高的线程分配时间片的数量要多余优先级低的线程。

​ 设置线程优先级时,针对频繁阻塞(休眠或者I/O操作)的线程需要涩会较高优先级,而偏重计算(需要较多CPU时间或者偏运算)的线程则设置较低的优先级,确保处理器不会被独占。

​ 在不同的JVM以及操作系统上,线程规划存在差异,有些操作系统甚至会忽略对线程优先级的设定。

4.1.4 线程的状态

状态码 状态名称 说明
NEW 初始状态 线程被构建,但是还没有调用start()方法
RUNNABLE 运行状态 Java线程将操作系统中的就绪和隐形两种状态统称为“运行中”
BLOCKED 阻塞状态 标识线程阻塞于锁
WAITING 等待状态 进入该状态表示当前线程需要其他线程做出一些特定动作(通知或中断)
TIME_WAITING 超时等待状态 不同于WAITING,它是可以在指定的时间自行返回的
TERMINATED 终止状态 表示当前线程已经执行完毕

一天搞定Java并发编程_第1张图片

  • 线程创建后,调用start()方法开始执行,当线程执行wait()方法之后,线程进入等待状态;

  • 进入等待状态的线程需要依靠其它线程的通知才能够返回到运行状态;

  • 超时等待状态相当于在等待状态的基础上增加了超时限制,也就是超时时间到达时将会返回到运行状态;

  • 当线程调用同步方法时,在没有获取到锁的情况下,线程将会进入到阻塞状态;

  • 线程在执行Runnable的run()方法之后会进入到终止状态;

  • 阻塞状态是线程进入synchronized关键字修饰的方法或代码块时的状态,但是阻塞在java.concurrent包中Lock接口中的线程状态确实等待状态,因为java.concurrent包中的接口对于阻塞的实现均使用了LockSupport类中的相关方法

4.1.5 Daemon线程

  • Daemon线程是一种支持线程,因为它主要被用作程序中后台调度以及支持性工作
  • 当一个Java虚拟机中不存在非Daemon线程的时候,Java虚拟机将会退出
  • 可以通过调用Thread.setDaemon(true)将线程设置为Daemon线程
  • 注意:Daemon线程属性需要在启动线程之前设置,不能在启动线程之后设置
  • 注意:Java虚拟机在退出时,Daemon线程中的finally块并不一定会执行,因为当不存在非Daemon线程时,虚拟机会立即终止,所以不能依赖Daemon线程中的finally块来确保执行关闭或清理资源

4.2 启动和终止线程

4.2.1 构造线程

​ 在运行线程之前,首先要构造一个线程,线程对象在构造的时候需要提供线程所需要的属性,如线程所属的线程组、线程优先级、是否为Daemon线程等信息。

4.2.2 启动线程

​ 通过start()方法启动线程

4.2.3 中断线程

  • 定义:中断可以理解为线程的一个标志位属性,它表示一个运行中的线程是否被其它线程进行了中断;

  • 实现:其它线程通过调用该线程的interrupt()方法对其进行中断操作;

  • 检查:线程通过方法isInterrupted()来进行判断是否被中断;

  • 复位:静态方法Thread.interrupted()对当前线程的中断标识进行复位;

  • 终结状态的线程,中断状态始终是false;

    注意:从Java API中可以看到,许多声明抛出InterruptedException的方法(例如Thread.sleep(long millis)方法)这些方法在抛出InterruptedException异常之前,Java虚拟机会先将该线程的中断标识位清除,然后抛出InterruptedException,此时调用isInterrupted()方法将会返回false。

4.2.4 过期的suspend()、resume()和stop()

​ 分别代表 暂停、恢复、终止 线程,但是已经过期了,不建议使用;

​ 原因:以suspend()为例,在调用后,线程不会释放已经占有的资源(比如锁),而是占有着资源进入睡眠状态,这样容易引发死锁问题;stop()方法在终结一个线程时,不会保证线程的资源正常释放,通常是没有给予线程完成资源释放工作的机会,因此会导致程序可能工作在不确定状态下。

​ suspend和resume方法可以用后面提到的等待/通知机制来替代

4.2.5 安全地终止线程

​ 可以通过interrupt(中断)或cancel(取消)方法来终止线程,这样操作有机会去清理资源,而不是武断的将线程停止,因此这种终止线程的做法显得更加安全和优雅。

4.2.26 sleep、wait、yield、suspend方法对比

https://blog.csdn.net/Crazypokerk_/article/details/87171229

方法 调用者 运行位置 释放锁 说明
sleep Thread对象 / sleep()方法需要指定等待的时间,它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,该方法既可以让其他同优先级或者高优先级的线程得到执行的机会,也可以让低优先级的线程得到执行机会。但是sleep()方法不会释放“锁标志”,也就是说如果有synchronized同步块,其他线程仍然不能访问共享数据。
wait Object对象 同步块或方法 wait()方法需要和notify()及notifyAll()两个方法一起介绍,这三个方法用于协调多个线程对共享数据的存取,所以必须在synchronized语句块内使用,也就是说,调用wait(),notify()和notifyAll()的任务在调用这些方法前必须拥有对象的锁;同时使用wait方法后会释放锁。注意,它们都是Object类的方法,而不是Thread类的方法
yield Thread对象 / yield()方法和sleep()方法类似,也不会释放“锁标志”,区别在于,它没有参数,即yield()方法只是使当前线程重新回到可执行状态,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行,另外yield()方法只能使同优先级或者高优先级的线程得到执行机会,这也和sleep()方法不同
suspend Thread对象 / 不会释放资源,需要和resume方法配合使用,已经不建议使用

4.3 线程间通信

4.3.1 volatile和synchronized关键字

  • volatile:用来修饰字段(成员变量),就是告知程序任何对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新会共享内存,它能保证所有线程对变量访问的可见性;

  • synchronized:可以修饰方法或者以同步块的形式来使用,它主要确保多个线程在同一时刻,只能有一个线程处于方法或者同步块中,它保证了线程变量访问的可见性和排他性

下面对synchronized详细讲解:

​ 对于同步块的实现使用了monitorentermonitorexit指令,而同步方法则是依靠方法修饰符上的ACC_SYNCHRONIZED来完成的。无论采用哪种方式,其本质是对一个对象的监视器(monitor)进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。

​ 每个对象都有自己的监视器,当这个对象有同步块或者同步方法调用时执行方法的线程必须先获取到该对象的监视器才能进入同步块或者同步方法,而没有获取到监视器(执行该方法)的线程将会被阻塞在同步块和同步方法的入口处,进入Blocked状态。

下图描述了对象、对象的监视器、同步队列和执行线程之间的关系

一天搞定Java并发编程_第2张图片

4.3.2 等待/通知机制

方法 描述
notify() 通知一个在对象上等待的线程,使其从wait()方法返回,而返回的前提是该线程获取到了对象的锁
notifyAll() 通知所有等待在该对象上的线程
wait() 调用该方法的线程进入WAITING状态,只有等待另外线程的通知或被中断才会返回,需要注意,调用wait()方法后,会释放对象的锁
wait(long) 超时等待一段时间,这里的参数时间是毫秒,也就是等待n毫秒,如果没有通知就超时返回
wait(long, int) 对于超时时间更细粒度的控制,可以达到纳秒级

等待/通知机制,是指一个线程A调用了对象O的wait()方法进入等待状态,而另一个线程B 调用了对象O的notify()或者notifyAll()方法,线程A收到通知后从对象O的wait()方法返回,进而执行后续操作

细节说明:

  • 使用wait()、notify()和notifyAll()时需要先对调用对象加锁
  • 在同步块中调用对象的wait()方法的同时会释放锁
  • 调用wait()方法后,线程状态由RUNNING变为WAITING,并将当前线程防止到对象的等待队列
  • notify()或notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()或notifyAll()的线程释放锁之后,等待线程才有机会从wait()返回;
  • notify()方法将等待队列中的一个等待线程从等待队列中移动到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移动到同步队列,被移动的线程状态由WAITING变为BLOCKED;
  • 从wait()方法返回的前提是获得了调用对象的锁。

从上述细节中可以看到:等待/通知机制依托于同步机制,其目的就是确保等待线程从wait()方法返回时能够感知到通知线程对变量做出的修改

一天搞定Java并发编程_第3张图片

4.3.3 等待/通知的经典范式

等待方遵循如下原则

  • 获取对象的锁;

  • 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件;

  • 条件满足则执行对应的逻辑。

    伪代码:

    synchronized(对象){

    ​ while(条件不满足){

    ​ 对象.wait();

    ​ }

    ​ 对应的处理逻辑

    }

通知方遵循如下原则

  • 获取对象的锁;

  • 改变条件;

  • 通知所有等待在对象上的线程。

    伪代码:

    synchronized(对象){

    ​ 改变条件

    ​ 对象.notifyAll();

    }

4.3.4 管道输入/输出流

​ 管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。

​ 主要有4种具体实现:PipedOutputStream、PipedInputStream、PipedReader和PipedWriter

​ 对于Piped类型的流,必须先要进行绑定,也就是调用connect()方法,如果没有将输出/输出流绑定起来,对于该流的访问将会抛出异常

4.3.5 Thread.join()的使用

  • 如果一个线程A执行了thread.join()方法,其语义是:当前线程A等待thread线程终止之后才从thread.join()返回;

  • 当线程终止时,会调用自身的notifyAll()方法,通知所有等待在该线程对象上的线程;

  • 所以,join方法其实使用的是等待/通知机制:synchronized的对象是thread,等待的线程是A,notifyAll()方法是在线程thread终止时自动调的

  • 通过阅读join方法源码也能看出使用的是等待/通知机制:

    //加锁当前线程对象,即thread
    public final synchronized void join() throws InterrupterException {
        //条件不满足,继续等待
        while(isAlive()){
            wait(0);
        }
        //条件符合,方法返回
    }
    

4.3.6 ThreadLocal深度解析

https://www.jianshu.com/p/98b68c97df9b

  • Thread对象维护着一个ThreadLocalMap属性,但是需要通过ThreadLocal来操作这个属性;
  • ThreadLocalMap没有继承Map,而是自己实现的Map存储,而且key限定死了必须是ThreadLocal对象;
  • ThreadLocalMap的key是弱引用,而value是强引用,所以key容易发生GC,而value一直存在,所以会造成内存溢出,所以使用时,使用完get和set方法,需要remove来解除引用关系,这样就能被GC了
  • ThreadLocalMap结构非常简单,没有next引用,也就是没有使用链表的方式来解决hash冲突,ThreadLocalMap解决Hash冲突的方式就是简单的步长加1或减1,寻找下一个相邻的位置,如果有大量不同的ThreadLocal对象放入map中时发送冲突,或者发生二次冲突,则效率很低,所以这里引出的良好建议是:每个线程只存一个变量,这样的话所有的线程存放到map中的Key都是相同的ThreadLocal,如果一个线程要保存多个变量,就需要创建多个ThreadLocal,多个ThreadLocal放入Map中时会极大的增加Hash冲突的可能

4.4 线程应用实例

4.4.1 等待超时模式

​ 在等待/通知的经典范式,即加锁、条件循环和处理逻辑3个步骤的基础上,增加超时条件,可参考join()方法源码:

//加锁当前线程对象,即thread
public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {//当isAlive返回值不满足要求,继续等待
            while (isAlive()) {
                wait(0);
            }
        } else {//当超时大于0并且isAlive返回值不满足要求,继续等待
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);//等待时间一直在变化,都是 总等待时间-已经等待过的时间
                now = System.currentTimeMillis() - base;
            }
        }
    	//条件满足需求,返回
    }

​ 开始觉得这个可以直接用wait(long mills)方法代替,但是细看之后发现是有区别的,直接调用wait(long mills)方法会一直超时等待mills时间,超时后继续下一个超时等待,这样一直不会被唤醒,所以用如上范例,在wait(long mills)方法外面增加一个总超时时间的限制,而且wait的时间一直是动态变化的,一直是future-now,也就是一定会在mills时间内返回的,经典的例子可以参考join(long mills)方法

五、Java中的锁

5.1 Lock接口

​ Lock缺少了synchronized提供的隐式获取释放锁的便捷性,但是却拥有了锁获取与释放的可操作性、可中断性以及超时获取锁等多种synchronized关键字所不具备的同步特性:

特性 描述
尝试非阻塞地获取锁 当前线程尝试获取锁,如果这一时刻没有被其它线程获取到,则成功获取并持有锁
能被中断地获取锁 与synchronized不同,获取到锁的线程能响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放
超时获取锁 在指定的截止时间之前获取锁,如果截止时间到了仍旧无法获取锁,则返回、

​ Lock的API:

方法名称 描述
void lock() 获取锁,调用该方法当前线程将会获取锁,当锁获得后,从该方法返回
void lockInterruptibly() throws InterruptedException 可中断地获取锁,和lock()方法不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
boolean tryLock() 尝试非阻塞的获取锁,调用该方法后立即返回,如果能够获取则返回true,否则返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException 超时的获取锁,当前线程在以下3中情况下会返回:
当前线程在超时时间内获得了锁
当前线程在超时时间内被中断
超时时间结束,返回false
void unlock() 释放锁
Condition newCondition() 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将会释放锁

5.2 AbstractQueuedSynchronizer(AQS)

  • 队列同步器(AQS)是用来构建锁或者其他同步组件的基础框架

  • 它使用了一个int成员变量表示同步状态

  • 通过内置的FIFO队列来完成资源获取线程的排队工作

  • 同步器主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态

  • AQS提供了三个方法来操作同步状态

    • getState():获取当前同步状态

    • setState(int newState):设置当前状态(获取锁时使用

    • compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性(释放锁时使用

  • 子类推荐被定义为自定义同步组件的静态内部类

  • AQS自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用

  • AQS既可以支持独占式获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLock、ReentrantReadWriteLock和CountDownLatch等)

5.2.0锁和同步器的关系:

  • 同步器是实现锁的关键,在锁的实现中聚合同步器,利用同步器的实现锁的语义;
  • 锁是面向使用者的,它定义了使用者与锁交互的接口,隐藏了实现细节;
  • 同步器面向的是锁的实现这,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作

5.2.1 AQS的接口与实例

AQS可重写的方法:

方法名称 描述
protected boolean tryAcquire(int arg) 独占式获取同步状态,实现该方法需要查询当前状态并判断同步状态是否符合预期,然后再进行CAS设置同步状态
protected boolean tryRelease(int arg) 独占式释放同步状态,等待获取同步状态的线程将有机会获取同步状态
protected int tryAcquireShared(int arg) 共享式获取同步状态,返回大于等于0的值,表示获取成功,反之,获取失败
protected boolean tryReleaseShared(int arg) 共享式释放同步状态
protected boolean isHeldExclusively() 当前同步器是否在独占模式下被线程占用,一般该方法表示是否被当前线程所
AQS提供的模板方法:
  • 独占式获取释放同步状态
  • 共享式获取与释放同步状态
  • 查询同步队列中等待线程的情况
方法名称 描述
void acquire(int arg) 独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则将会进入同步队列等待,该方法将会调用重写的tryAcquire(int arg)方法
void acquireInterruptibly(int arg) 与acquire(int arg)相同,但是该方法响应中断,当前线程未获取到同步状态而进入同步队列中,如果当前线程被中断,则该方法会抛出InterruptException并返回
boolean tryAcquireNanos(int arg, long nanos) void acquireInterruptibly(int arg)基础上增加了超时限制,如果当前线程在超时时间内没有获取到同步状态,那么将会返回false,如果获取到了返回true
void acquireShared(int arg) 共享式的获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式获取的主要区别是在同一时刻可以有多个线程获取到同步状态
void acquireSharedInterruptibly(int arg) 与acquireShared(int arg)相同,该方法响应中断
blooean acquireSharedNanos(int arg, long nanos) 在acquireSharedInterruptibly(int arg)基础上增加了超时限制
boolean release(int arg) 独占式的释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒
boolean releaseShared(int arg) 共享式的释放同步状态
Collection getQueuedThreads() 获取等待在同步队列上的线程集合

5.2.2 AQS的实现分析

1. 同步队列

​ AQS依赖内部的同步队列(一个FIFO双向队列)来完成同步状态管理,当前线程获取同步状态失败时,同步器会将当前线程以及登台状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

​ 同步队列中的节点(Node)用来保存获取同步状态失败的线程引用、等待状态以及前驱和后继节点,节点的属性类型与名称以及描述如下:

属性类型与名称 描述
int waitStatus 等待状态
包含如下状态:
1.CANCELLED,值为1,由于在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待,节点进入该状态将不会变化;
2.SIGNAL,值为-1,后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或被取消,将会通知后继节点,使后继节点的线程得以运行;
3.CONDITION,值为-2,节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列中,加入到对同步状态的获取中;
4.PROPAGATE,值为-3,表示下一次共享式同步状态获取将会无条件地被传播下去;
5.INITIAL,值为0,初始状态
Node prev 前驱节点,当节点加入同步队列时被设置(尾部添加)
Node next 后继节点
Node nextWaiter 等待队列中的后继节点,如果当前节点是共享的,那么这个字段将是一个SHARED常量,也就是说节点类型(独占和共享)和等待队列中的后继节点共用一个字段
Thread thread 获取同步状态的线程
  • 节点是构成同步队列的基础,同步器拥有首节点(head)和尾节点(tail),没有成功获取同步状态的线程将会成为节点加入该队列的尾部;
  • 设置尾节点需要用到同步器基于CAS的方法:compareAndSetTail(Node expect, Node update);
  • 首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点;
  • 设置首节点是通过获取同步状态成功的线程来完成的,是线程安全的,不需要CAS操作,它只需要将首节点设置成为原首节点的后继节点并断开原节点的next引用即可;
2.独占式同步状态获取与释放
  • 在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;

  • 移出队列的条件是前驱节点为头节点且成功获取了同步状态;

  • 在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

    一天搞定Java并发编程_第4张图片

一天搞定Java并发编程_第5张图片

​3.共享式同步状态获取与释放
  • 和独占式类似,共享式同步状态也是维护一个同步队列,只是共享式可以允许多个线程同时获取到锁
  • 退出自旋的条件是当前节点的前驱节点是head,并且**tryAcquireShared()**方法返回值 >= 0
  • 获取到同步状态后调用方法setHeadAndPropagate(),目的是除了要设置head为当前节点,同时需要唤醒其后继节点,这是与独占锁差别最大的地方
  • 释放锁的时候调用doReleaseShared()方法,不过要注意这里可能有多个线程来释放锁,所以需要CAS操作
  • 共享锁释放方法**doReleaseShared()有两个入口,一个是releaseShared()方法(此时是线程主动释放锁);另一个就是doAcquireShared()**方法中(当线程获取到锁之后释放后继的共享节点)

一天搞定Java并发编程_第6张图片

4.独占式超时获取同步状态
  • 通过调用同步器的doAcquireNanos(int arg, long nanosTimeout)方法可以超时获取同步状态,此功能是synchronize关键字所不具备的特性;
  • Java 5中,同步器提供了acquireInterruptibly(int arg)防范,这个方法在等待获取同步状态时,如果当前线程被中断,会立即返回,并抛出InterruptedException
  • 超时获取同步状态过程可以被视作响应中断获取同步状态过程的“增强版”,也就是**acquireNanos(int arg, long nanosTimeout)**方法不仅支持超时返回,同时支持响应中断返回
  • 自旋有一个最小时间spinForTimeoutThreshold限制,默认值为1000L纳秒,如果线程剩余的等待时间小于或等于这个时间限制,则不会进行等待,而是直接接着自旋。因为非常短的超时等待无法做到十分精确,所以,在超时非常短的场景下,同步器会进入无条件的快速自旋
    一天搞定Java并发编程_第7张图片

5.3 重入锁(ReentrantLock)

Lock接口 ReentrantLock实现
lock() sync.acquire()
lockInterruptibly() sync.acquireInterrunptibly(1)
tryLock() sync.nonfairTryAcquire(1)
tryLock(long time, TimeUnit unit) sync.tryAcquireNanos(1, unit.toNanos(timeout))
unlock() sync.release(1)
newCondition() sync.newCondition()

ReantrantLock有三个重要特性:

  • ReentrantLock是独占锁,所以内部的Sync没有重写AQS中的shared()相关方法
  • ReentrantLock是重入锁,也就是一个线程可以重复获取同一个锁
  • ReentrantLock有公平锁和非公平锁两种实现,默认非公平锁(因为性能比较好)

可以根据以上三点来理解ReentrantLock:

1.ReentrantLock是独占锁

​ ReentrantLock中的同步器重写了AQS的tryAcquire()和tryRelease()方法,没有重写shared相关方法,所以是一个地地道道的独占锁,不支持共享锁机制

2.ReentrantLock是重入锁

​ 重入的意思是一个线程获取了锁,然后再来获取锁可以直接得到锁,而不需要进入同步队列进行排队,实现原理就是tryAcquire()方法中的一段:

			//如果尝试获取锁的线程就是当前获取到锁的线程则直接返回true
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);	//注意这里设置了增加了state,后续释放锁的时候需要用到
                return true;
            }
  • 在公平锁和非公平锁中都有这个一段,这里实现了锁重入的机制。

  • 这里不但返回了true,而且state值增加了,这里就是重入锁的关键,也就是state记录了锁被重复获取的次数,在释放锁的时候,需要释放同样多次才完成锁的释放

3.公平锁和非公平锁

公平锁是尝试获取锁的时候,如果有同步队列,则自动加到队尾,等待前面的获取完成后再来获取锁,保证按顺序获取到锁,即先到先得;

非公平锁则是在尝试获取锁的时候,先直接获取一下锁,如果获取到了直接返回true,没有获取到再去队尾排队,这样造成的结果就是可能有的线程一直在等待而获取不到锁的情况发生

公平锁尝试获取锁:

		//ReentrantLock公平锁尝试获取锁
		protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                //这里是和非公平锁唯一区别的地方
                //公平锁会先判断一下是否存在同步队列,存在就直接去队尾排队
                //而非公平锁不会先判断是否有同步队列,而是直接尝试获取锁
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //如果尝试获取锁的线程就是当前获取到锁的线程则直接返回true
            else if (current == getExclusiveOwnerThread()) {
                //每重复获取一次 state 值就增加一次
                //对应着释放锁的时候,每释放一次就需要减一次
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

非公平锁尝试获取锁:

        //ReentrantLock非公平锁尝试获取锁		
        final boolean nonfairTryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                //直接CAS,不需判断是否有同步队列
                if (compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                //每重复获取一次 state 值就增加一次
                //对应着释放锁的时候,每释放一次就需要减一次
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }

可以看到唯一的区别就是公平锁在尝试获取锁的时候先执行**hasQueuedPredecessors()**这个方法

4.ReentrantLock锁的释放

		//ReentrantLock尝试释放锁
		protected final boolean tryRelease(int releases) {
            //对应之前加锁时 state 值做了加法,这里需要做减法
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            //必须要将 state 减到0才算完成锁的释放
            if (c == 0) {
                free = true;
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }
  • 由于ReentrantLock是可重入锁,所以在同一个线程重入的时候需要对state做加法,释放的时候需要对state做减法,也就是重入多少次就需要释放多少次

  • 由于ReentrantLock是独占锁,同一时间只会有一个线程来释放锁

  • 所以重入锁需要保证加锁多少次就需要释放多少次,也就是lock()多少次,就需要unlock()多少次

一天搞定Java并发编程_第8张图片

5.公平锁和非公平锁对比

公平锁保证了锁的获取按照FIFO的原则,而代价是进行大量的线程切换,影响性能;

非公平锁虽然可能造成“饥饿”,但极少的线程切换,保证了其更大的吞吐量

5.4 读写锁(ReentrantReadWriteLock)

读写锁中既有独占锁(写锁)也有共享锁(读锁)

AQS中state的高16位作为读锁标识,低16位作为写锁标识

5.4.1读锁的获取

  • 获取读锁时,如果有写锁并且写锁的持有者不是本线程,会被阻塞,其它情况都可以获取
  • 当读锁被获取时,所有线程都可以再次获取读锁(共享锁),但是所有线程都不能来获取写锁

5.4.2写锁的获取

  • 当写锁被获取到时,非当前线程读写操作都会被阻塞当前线程可以获取读锁也可以再次获取写锁(重入)
  • 获取写锁时,只要存在读锁,就阻塞;如果存在的写锁不是本线程,也会被阻塞

5.4.3锁降级

  • 锁降级是指,同一个线程获取到写锁不释放,获取到读锁,再释放写锁的过程

  • 必须要在写锁释放前获取到读锁,不然不能称为降级锁

    			//获取写锁
    			writeLock.lock();
                try {
                    if (!update) {
                        // 准备数据的流程(略)
                        update = true;
                    }
                    //释放写锁之前获取读锁
                    readLock.lock();
                } finally {
                    //释放写锁
                    writeLock.unlock();
                }
                // 锁降级完成,写锁降级为读锁
    

    读写锁的获取关系总结如下:

被获取的锁 当前线程tryReadLock 当前线程tryWriteLock 其它线程tryReadLock 其它线程tryWriteLock
ReadLock Y(锁重入) N(数据可见性) Y(共享锁) N(数据可见性)
WriteLock Y(锁降级) Y(锁重入) N(数据可见性) N(数据可见性)

一天搞定Java并发编程_第9张图片

5.5 LockSupport工具

方法名称 描述
void park() 阻塞当前线程,如果调用unpark(Thread thread)方法或者当前线程被中断,才能从park()方法返回
void parkNanos(long nanos) 阻塞当前线程,最长不超过nanos纳秒,返回条件在park()的基础上增加了超时返回
void parkUnit(long deadline) 阻塞当前线程,直到deadline时间(从1970年到deadline时间的毫秒数)
void unpark(Thread thread) 唤醒处于阻塞状态的线程thread

​ 除此之外,在Java6中,增加了**park(Object blocker)、park(Object blocker, long nanos)和park(Object blocker, long deadline)**3个方法,其中的参数blocker用来标识当前线程在等待的对象,该对象主要用于问题排查和系统监控。

​ 比如通过 jstack pid 命令查看线程状态:

"线程2" #12 prio=5 os_prio=0 tid=0x000000001c639000 nid=0xfd24 waiting on condition [0x000000001d0ee000]
   java.lang.Thread.State: TIMED_WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        //这里比线程1多显示了当前等待的对象,即传入的 blocker
        //这里比线程1多显示了当前等待的对象,即传入的 blocker
        //这里比线程1多显示了当前等待的对象,即传入的 blocker
        - parking to wait for  <0x0000000780990348> (a book.chapter05.ProcessData)
        at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
        at book.chapter05.ProcessData.lambda$main$1(ProcessData.java:48)
        at book.chapter05.ProcessData$$Lambda$2/999966131.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)

"线程1" #11 prio=5 os_prio=0 tid=0x000000001c634800 nid=0x13444 waiting on condition [0x000000001cfee000]
   java.lang.Thread.State: TIMED_WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:338)
        at book.chapter05.ProcessData.lambda$main$0(ProcessData.java:45)
        at book.chapter05.ProcessData$$Lambda$1/1149319664.run(Unknown Source)
        at java.lang.Thread.run(Thread.java:748)

线程2用的是park(Object blocker, long nanos),线程1用的是void parkNanos(long nanos)

可以看出来:线程2比线程1多显示了当前等待的对象,即传入的 blocker,有益于问题的排查和系统监控

5.6 Condition接口

​ 任意一个Java对象都拥有一组监视器方法(定义在java.lang.Object上),主要包括:wait()、wait(long timeout)、notify()、notifyAll(),这些方法和synchronized关键字结合使用,可以实现等待/通知模式。

​ Condition接口也提供了类似Object的监视器方法,与Lock接口配合使用实现等待/通知模式。对比Object的监视器方法和Condition接口,可以更加详细的了解Condition 的特性:

一天搞定Java并发编程_第10张图片

5.6.1 Condition接口与示例

​ Condition定义了等待/通知两种类型的方法,当前线程调用这些方法时,需要提前获取到Condition对象关联的锁。Condition对象是由Lock对象(调用Lock对象的newCondition()方法)创建出来的,换句话说,Condition是依赖Lock对象的。

获取一个Condition必须通过Lock的newCondition()方法

​ Condition的等待/通知模型:

			//等待
			lock.lock();
            try {
                while (条件不成立) {
                    condition.await();
                }
                //线程继续执行
            } finally {
                lock.unlock();
            }

			//通知
			lock.lock();
            try {
                改变条件
                condition.signal();//唤醒等待线程
            } finally {
                lock.unlock();
            }

​ Condition定义的(部分)方法以及描述:

一天搞定Java并发编程_第11张图片

5.6.2 Condition的实现分析

  • ConditionObject是同步器AQS的内部列,应为Condition的操作需要获取相关的锁,所以作为同步器的内部类是比较合理的。
  • 每个Condition对象都包含着一个队列(一下简称等待队列),该队列是Condition对象实现 等待/通知 功能的关键
  • 下面说的Condition没有特殊说明的话都是指的ConditionObject
1.等待队列
  • 等待队列是一个FIFO双向队列,在队列的每个节点中都包含了一个线程的引用,该线程就是在Condition对象上等待的线程

  • 如果一个线程调用了Condition.await()方法,那么该线程将会释放锁、构造节点加入等待队列(类似Object.wait())

  • 在AQS中,等待队列用的也是AQS中的Node,和同步队列一样

  • 一个Condition包含一个等待队列

  • Condition拥有首节点(firstWaiter)和尾节点(lastWaiter)

  • 当前线程调用Condition.await()方法将会以当前线程构造节点,并将节点从尾部加入等待队列

    一天搞定Java并发编程_第12张图片

    ​ 如上图所示,Condition拥有首尾节点的引用,而新增节点只需将原有的尾节点 nextWaiter 指向它,并且更新尾节点(lastWaiter)即可。

    上述节点引用更新的过程并没有使用CAS保证,原因在于调用await()方法的线程必定是获取了锁的线程,也就是说该过程是由锁来保证

    Object的监视器模型上,一个对象拥有一个同步队列和等待队列,而并发包中的Lock(更确切的说应该是同步器AQS)拥有一个同步队列和多个等待队列,其对应关系如图所示:

    一天搞定Java并发编程_第13张图片

    Condition的实现是同步器的内部类,隐刺每个Condition实例都能够访问同步器提供的方法,相当于每个Condition都拥有所属同步器的引用。

2.等待
调用Condition的await()方法(或者await开头的方法),会使当前线程进入等待队列,**并释放锁**,同时线程变为等待状态。当从await()方法返回时,当前线程一定获取了Condition相关联的锁
  • 注意等待节点不是将head节点移动到nextWaiter,而是通过addConditionWaiter()方法,把当前线程构造成一个新的节点并将其加入到等待队列中
  • 释放锁时head的移除在acquireQueue(Node node)中,通过下一个获取到锁的线程实现

一天搞定Java并发编程_第14张图片

源码:

		/**
		 *Condition.await()
		 */
		public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
            //当前线程加入等待队列,需要构造新的节点
            Node node = addConditionWaiter();
            //释放同步队列,也就是释放锁
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            //这里就是等待唤醒的条件(节点是否进入同步队列)
            while (!isOnSyncQueue(node)) {
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }
		/**
		 *  构造新的节点加入等待队列尾部
		 *  @return 构造的新节点
		 */
		private Node addConditionWaiter() {
            Node t = lastWaiter;
            // If lastWaiter is cancelled, clean out.
            if (t != null && t.waitStatus != Node.CONDITION) {
                unlinkCancelledWaiters();
                t = lastWaiter;
            }
            Node node = new Node(Thread.currentThread(), Node.CONDITION);
            if (t == null)
                firstWaiter = node;
            else
                t.nextWaiter = node;
            lastWaiter = node;
            return node;
        }
		/**
         * 完全释放锁
         * @param 根据当前线程构造的等待节点
         * @return 前驱节点的 state
         */
        final int fullyRelease(Node node) {
            boolean failed = true;
            try {
                int savedState = getState();
                if (release(savedState)) {
                    failed = false;
                    return savedState;
                } else {
                    throw new IllegalMonitorStateException();
                }
            } finally {
                if (failed)
                    node.waitStatus = Node.CANCELLED;
            }
        }
3.通知

​ 调用Condition的signal()方法,降火唤醒在等待队列中等待时间最长的节点(即首节点),在节点在被唤醒之前,会被移动到同步队列中:

一天搞定Java并发编程_第15张图片

  1. 调用Signal()方法的前置条件是当前线程必须获取了锁,在signal()方法中进行了isHeldExclusively()检查,也就是当前线程必须是获取了锁的线程
  2. 接着获取等待队列的首节点,将其移动到同步队列并使用LockSupport唤醒节点中的线程
  3. 通过调用同步器的enq(Node node)方法,等待队列的投节点线程安全的移动到同步队列
  4. 当节点移动到同步队列后,当前线程再使用LockSupport唤醒该节点的线程
  5. 被唤醒的线程,将从await()方法中的while循环中退出(isOnSyncQueued())方法加入到获取同步状态的竞争中

源码:

			public final void signal() {
                //这里判断调用signal的线程必须是获取了锁的线程
                if (!isHeldExclusively())
                    throw new IllegalMonitorStateException();
                Node first = firstWaiter;
                if (first != null)
                    //唤醒操作
                    doSignal(first);
            }

Condition的signalAll()方法,相当于对等待队列中的每个节点均执行一次signal方法,效果就是将等待队列中所有节点全部移动到同步队列中,并唤醒每个节点的线程

你可能感兴趣的:(java)