Java 并发编程总结

并发编程有三大特性,原子性、有序性、可见性。我们先通过volatile 了解下,为什么volatile 能保证并发编程的有序性和可见性 而不能保证原子性。

  • 先介绍下什么是 可见性、原子性、有序性
    • 可见性
      • 可见性就是,一个线程修改了一个变量的值,另外一个线程立刻可以感知到。是由CPU缓存导致的可见性问题
    • 并发编程为什么会有可见性的问题?
      • 因为CPU是有自己的缓存的,CPU执行计算时,会把变量从内存加载到CPU缓存计算,之后再对这个变量计算就不会再从缓存加载了。
        • 这么设计的原因是,CPU从内存加载数据和CPU本身的计算相比,太慢了。加载数据的过程可能是计算本身的几百倍,这就导致CPU大部分时间都在等待,CPU利用率极低。所以就硬件工程师弄出了CPU缓存。
      • 现在的计算机大多都是多核的,比较常见的就是4核8G。如果一个计算机有多个CPU,那就意味着每个CPU都有自己的缓存。如果是多线程执行,那可能是多个CPU分别执行不同的线程。
      • 如果是多个线程读写同一个共享变量。比如线程A去修改 i 的值,i++。线程B去读取 i 的值。
        • i++ 需要执行3条CPU指令,线程A的执行步骤是这样的
          • 加载 i 的值到CPU缓存
          • 计算 i 的值
          • 把计算好的值写回缓存
        • 这样,线程B 感知不到线程A修改了 i ,因为线程B是从内存里读取的 i,线程A并没有把修改后的值写回内存。
          • 所以,这就是CPU缓存导致的可见性问题。
  • 那 volatile 为什么可以保证多线程的可见性呢
    • 基于lock前缀指令和MESI缓存一致性协议来保证可见性的
    • 对volatile修饰的变量,执行写操作的话,JVM会发送一个lock前缀指令给CPU,CPU在计算完后会立刻将这个值写回内存。同时因为有MESI缓存一致性协议,各个CPU会对总线进行嗅探,自己本地缓存中的数据是否被别人修改,如果被修改,缓存失效,重新从内存读取
    • 所以,通过lock前缀指令和MESI缓存一致性协议,就可以保证线程间的可见性

 

  • 原子性
    • 一个操作或多次操作,要么全部执行,要么都不执行,称为原子性。原子性是由多线程并发,线程切换导致的
    • 我们高级语言里的一个操作,可能涉及到多条CPU指令,比如i++,就要三条指令
      • 加载 i 的值到CPU缓存
      • 计算 i 的值
      • 把计算好的值写回缓存或内存
    • volatile为什么不能保证原子性,两个线程同时 写 共享变量 会出现问题
      • 也就是说,volatile可以允许一个线程修改共享变量,一个线程读取共享变量。不能保证两个线程同时修改共享变量。
      • 比如一个变量temp,存在方法区,线程1,线程2,同时对temp++;
      • 解释下为啥不能保证原子性:(总结就是,在写内存那步发生线程切换)
        • 1)线程1去方法区读取temp,存到工作内存,temp=10;
        • 2)线程2去方法区读取temp,存到工作内存,temp=10;
        • 3)线程1,执行temp++;此时线程1的工作内存,temp=11;
        • 4)线程1此时想要把temp写回到主存,但是发生了线程切换;
        • 5)线程2,把工作内存的temp++,temp=11,然后把temp写到主存。此时所有线程的temp缓存失效;
        • 6)线程1的temp缓存失效,但是! 线程1已经计算完temp的值了,根不就不需要缓存的temp,之前已经计算完temp=11,就差一步,就是把temp=11写到主存;
        • 7)所以,线程1,又把tmep=11写到了主存,这就不对了,如果是正常情况下,temp应该等于12;
        • 8)所以 volatile是不能保证原子性的,也就是不能支持 两个线程并发的修改同一条数据。如果想要保证就要用锁了;

 

  • 有序性
    • 编译优化带来的有序性问题
    • 有序性就是,正常编译器执行我们的代码,是按顺序执行的,不过有时候,在代码顺序对程序的结果没有影响时,编译器可能会为了性能,改变代码的顺序。
    • 如果是单个线程,即使编译器优化指令顺序,对结果没有影响的话,也是无所谓的,但是并发编程就不行了。
    • 并发编程为什么会有有序性的问题
      • 最经典的并发编程的有序性问题就是 单例模式的 volatile + double check。
      • 顺便解释下,为什么double check 不行,一定要加volatile,代码如下
 
  1. public class Singleton {

  2. private static Singleton instance;

  3.  
  4. public static Singleton getInstance(){

  5. if (instance == null) {

  6. synchronized(Singleton.class) {

  7. if (instance == null)

  8. instance = new Singleton();

  9. }

  10. }

  11. return instance;

  12. }

  13. }

 

  •   假设线程 A 先调用 getInstance() 方法,instance == null 判断成立,所以进入后 对 Singleton 加锁,此时第二个instance == null 还是成立的,所以再执行 instance = new Singleton();
    • 问题出现在 new 操作上,正常来说 new 操作的指令是三条
      • 分配一块内存 M;
      • 在内存 M 上初始化 Singleton 对象;
      • 然后 M 的地址赋值给 instance 变量。
    • 但是经过编译器优化后的指令是这样的
      • 分配一块内存 M;
      • 将 M 的地址赋值给 instance 变量;
      • 最后在内存 M 上初始化 Singleton 对象。
    • 假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上
    • 如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常

 

  • 那volatile如何解决double check问题呢
    • 使用volatile可以禁止指令重排,这个例子我们就是禁止了instance变量的指令重排,那如果在第二个指令执行完发生切换,此时没有给instance赋值地址,所以instance还是null的。线程B会进入第一个判断里面,等待线程A释放锁,等线程A把第三个指令执行完,释放了锁,线程B才会执行,就没有问题了。

 

  • 总结,然后说下 volatile 底层原理
    • 是基于happens-before规则 ,来保证有序性的
      • volatile 变量规则:对volatile修饰的变量的写操作必须在读操作之前
      • 内存屏障:就是在volatile指令的前后加一些屏障,禁止指令重排,内存屏障分几种。load (加载)、store(存储)这个简单了解,说一下就行
        • LoadLoad屏障:Load1;LoadLoad;Load2,确保Load1数据的装载先于Load2后所有装载指令,他的意思,Load1对应的代码和Load2对应的代码,是不能指令重排的
        • StoreStore屏障:Store1;StoreStore;Store2,确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令
        • LoadStore屏障:Load1;LoadStore;Store2,确保Load1指令的数据装载,先于Store2以及后续指令
        • StoreLoad屏障:Store1;StoreLoad;Load2,确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载

 

  • 基于lock前缀指令和MESI缓存一致性协议来保证可见性的
    • 所以,通过lock前缀指令和MESI缓存一致性协议,就可以保证线程间的可见性
      • 不用深究这两个东西,没什么意义
      • 只要知道 lock前缀指令 是一个CPU指令就好
      • MESI 缓存一致性协议,就是个协议。就是对共享变量嗅探,如果被修改就失效。这个也不用深究
    • 对volatile修饰的变量,执行写操作的话,JVM会发送一个lock前缀指令给CPU,CPU在计算完后会立刻将这个值写会内存。同时因为有MESI缓存一致性协议,各个CPU会对总线进行嗅探,自己本地缓存中的数据是否被别人修改

 

 

  • 如果多线程编程想要保证原子性,那就必须使用锁了,让所有线程串行化去修改共享变量

 

  • Java 中有两种方式可以加锁
    • 一种是使用Java 内置的关键字Synchronzied
    • 一种是实现了Lock接口的实现类
  • 先介绍 Synchronzied
    • Sychronzied 的使用:
      • Synchronzied 平时使用的时候,有4种方式
        • 1)Synchronzied 方法
          • 如果加的普通的方法上,加锁的目标是对象
          • 如果加到static方法上,加锁的目标是方法区的class类
        • 2)Synchronzied 代码块
          • 加锁的目标是对象
        • 3)Synchronzied(this)
          • 加锁的目标是对象
        • 4)Synchronzied(类名.class)
          • 加锁的目标是方法区的class类
    • 自动加锁释放锁:
      • Synchronzied 会在Synchronzied方法,或者Synchronzied代码块里的自动加锁和释放锁
        • 两个JVM指令,monitorenter 、monitorexit
        • 如果我们的方法加了Synchronzied,那编译后的指令是这样的
          • 线程如果遇到了monitorenter ,必须去对应的对象上加锁,才能执行下面的代码
          • 遇到了monitorexit 会释放锁
          • 具体如何加锁的,下面解释
 
  1. monitorenter // 加锁

  2. // 对应方法的指令

  3. monitorexit // 释放锁

 

  • Synchronzied 的底层原理
    • 实际上,Synchronzied底层是通过Monitor 来加锁的
    • 这个要从JVM的内存模型说起,因为Synchronzied 加锁可能在堆中,也可能在方法区中,我先画个图,这是JVM的内存模型

 

  • 然后,如果我们使用Synchronzied 给对象加锁的话,那就得先介绍下JVM 堆内存里对象的结构
    • 堆中的一个对象(图1),主要分三部分:
      • 1)对象头
        • Mark Word(图2)
          • 锁的标志位(分几种锁,这个后面单独说)
          • 指向monitor的指针(重量级锁的指针)
            • 线程通过这个指针的地址,去对象对应的monitor中加锁
          • 对象的分代年龄(垃圾回收)
          • 对象的hashcode
          • 等等
        • Class Metedata Address
          • 这个就是指向永久代的,这个对象对应的class文件
      • 2)实例变量
        • 存放类的属性数据信息,包括父类的属性信息
      • 3)填充数据
        • 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

 

  • 然后说一下 Monitor
    • Monitor 也叫管程,不止Java有,是实现锁的一种方式。
    • Java 中每个对象都有自己对应的 Monitor
      • Monitor 的创建
        • 当线程想要对这个对象加锁时,自动创建一个与这个对象对应的 Monitor
    • 如果是多线程,都想修改这个变量,想要给这个对象加锁,那首先要通过对象头的Mark Word中的 Monitor 指针,找到这个对象对应的 Monitor
    • Monitor的底层结构
      • JVM 的 Monitor 是由ObjectMonitor实现的
      • 位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的
        •  
          1. ObjectMonitor() {

          2. _header = NULL;

          3. _count = 0; //记录个数

          4. _waiters = 0,

          5. _recursions = 0;

          6. _object = NULL;

          7. _owner = NULL; // 当前加锁线程

          8. _WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet

          9. _WaitSetLock = 0 ;

          10. _Responsible = NULL ;

          11. _succ = NULL ;

          12. _cxq = NULL ;

          13. FreeNext = NULL ;

          14. _EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表

          15. _SpinFreq = 0 ;

          16. _SpinClock = 0 ;

          17. OwnerIsThread = 0 ;

          18. }

           

        • 下面是 ObjectMonitor 的底层结构,如图(1)
          • 主要字段
            • count
              • 有两个值
                • 0:当前对象无人加锁
                • 1:当前对象已经加锁
            • owner
              • 当前加锁线程
            • entryList
              • 想加锁,但是没加上,阻塞等待加锁的线程
            • waitSet
              • 如果获取到锁的线程,调用了 wait() 方法,当前线程会进入这个列表

 

 

 

  • 然后,说一下三个线程(线程A、线程B、线程C)给对象加锁的流程
    • 线程A、线程B 通过对象的 Monitor 指针找到了这个对象的 Monitor ,进入了entryList
    • entryList 中的线程,通过CAS的方式,都去尝试把count 的值从0改为1
    • 假设线程A 成功了
    • 那 owner 的值,从null 变成 线程A,线程B就阻塞了
    • 然后此时,线程C 想要对对象加锁,但是发现count = 1,不能修改,进入entryList中阻塞
    • 此时,线程A 调用了 wait() 方法,线程A 释放锁,进入 waitSet列表等待别的线程调用notify() 。此时 count = 0,owner = null
    • 线程A释放了锁,在entryList中的线程B 和 线程C,会继续以CAS的方式,尝试修改count 的值,假设线程B成功了
    • 那线程C继续阻塞
    • 如果线程A 被唤醒,那线程A 会等待线程B释放锁后,和线程C一起对count尝试CAS操作,获取锁
    •  

 

  • 然后说一下JDK1.6 开始对 Synchronzied 锁的优化和锁的升级
    • 偏向锁、轻量级锁、重量级锁、自旋锁。这些不是单独的锁,这些都是Synchronzied的锁的实现。Synchrozied会根据不同的场景选择不同的锁,我们只使用Synchronzied,不用关心它具体使用的哪个锁。
    • 偏向锁
      • 这个意思就是说,monitorenter和monitorexit是要使用CAS操作加锁和释放锁的,开销较大。如果大多数情况下,都只有一个线程来竞争锁,那这个线程获取到锁后,就会进入偏向模式,Mark Word标记为偏向锁,下次这个线程再来加锁,无需做任何同步操作。
    • 轻量级锁
      • 如果偏向锁失败了,JVM 会尝试升级为轻量级锁。轻量级锁适用于,两个或者几个线程,交替执行,没有冲突。一旦有冲突就会升级重量级锁。
    • 重量级锁
      • 就是上面说的那种
    • 自旋锁
      • 适用于线程竞争特别激烈,并且每个线程执行的时间很短的情况。如果每个线程获取到锁,执行1ms,就释放锁。那100个线程这么来回加锁、释放锁。线程上下文切换的成本太高了。所以,自旋锁是这样的,如果一个线程获取不到锁,就空循环几次,不会发生上下文切换,等待机会获取到锁就继续执行。
    • 锁消除
      • 这个是对Sychronzied的一个优化,如果一段代码,连续出现几个Synchronzied代码块,编译器会尝试合并为一个。避免频繁的加锁、释放锁。

 

  • 再说下Synchronzied 的几个特性
    • 可重入性
      • Synchronzied是可重入锁,就是说,一个线程获取到锁后,还可以继续对这个对象加锁,加多层锁。
    • 线程中断问题
      • 当线程处于阻塞状态时,我们可以通过interrupt() 方法来中断该线程,然后在try catch中会抛出InterruptedException,抛出后线程会复位(变成非中断状态)
      • 当线程处于非阻塞状态时,interrupt() 方法无法让线程停止。必须在线程的run() 方法里加个判断 if (isInterrupted()){ 结束线程}
      • 使用Synchronzied 加锁,interrupt() 方法无法中断一个正在等待锁的线程。
    • wait、notify、notifyAll
      • 这几个方法都是Object内的方法,必须在Synchronzied内使用
        • 因为这几个方法都依赖于Monitor实现,如果不在Synchronzied内使用,可能这个对象根本就没对应的Monitor对象,会抛异常。
      • 调用wait方法,加锁线程会进入 waitSet列表等待,等待其它线程调用notify() 或者notifyAll()
      • wait方法会释放锁,上面Synchronzied原理那已经说了
    • Sleep
      • sleep不会释放锁,只是休眠。这个类是Thread里的,不是Objcet方法。可以在任何地方使用。
  • 然后说一下实现了Lock 接口的ReentrantLock

 

  • ReentrantLock
    • 使用方式和Synchronzied 不同
      • 需要创建个lock对象, Lock lock = new ReentrantLock() ,然后通过 lock对象加锁
    • 显式加锁
      • 加锁
        • lock.lock();
      • 释放锁
        • lock.unlock();
          • 必须在finally中调用,即使抛异常也会释放锁
    • 也是可重入锁
      • 支持对一个对象重复加锁
        • 如果加两次,那释放锁也要两次。分析底层原理的时候细说
    • 高级功能
      • 可指定公平锁
        • 构造函数传true,则是公平锁,默认非公平锁
          • 分析底层原理的时候细说
      • 在指定的时间内尝试获取锁
        • lock.tryLock(long time, TimeUnit unit)
        • 获取到了返回true,获取不到返回false
          • 不会像Synchronzied一直在那阻塞
      • 可中断正在等待锁的线程
        • 如果想要中断正在等待锁的线程,那加锁的时候不要使用lock.lock()方法
          • lock() 方法,优先考虑获取锁,获取锁后才考虑响应中断
        • 应该使用 lock.lockInterruptibly() 方法来加锁
          • lockInterruptibly() ,优先考虑响应中断,可在等待锁的时候中断

 

  • ReentrantLock 的底层原理
    • 是基于CAS + AQS 实现的
      • CAS : CompareAndSet
      • AQS:AbstractQueuedSynchronizer 抽象队列同步器

 

  • AQS 内部有三个核心组件,如图
    • 1)state
      • 由volatile 修饰,标志着线程是否可以获取锁
        • state = 0,没有线程获取锁
        • state = 1,已有线程获取锁
    • 2)AQS 内部队列
      • 没有获取到锁的线程,进入该队列
      • 该队列底层结构是双向链表,双向链表可以当做队列来用,比如LinkedList
        • 队尾进,队头出
          • 没获取到锁的线程,进入队尾,tail指针指向队尾
          • 只有head指向的节点,才可以获取锁
        • 每个节点由Node组成
          • 主要的字段
            • prev :前节点
            • next:后节点
            • thread:当前线程
            • 还有一些线程状态的字段
    • 3)当前加锁线程
      • 记录当前获取到锁的线程

  • 举例,三个线程获取锁的情况
    • 线程A、线程B、线程C 同时以CAS 的方式尝试获取锁
      • 调用lock方法,compareAndSetState(0, 1) // 只有state是0的时候才可以把它改成1
    • 只有一个线程可以把state的值从 0 改成 1,其他线程会失败
      • 以线程A获取到锁的时间节点的图

 

 

  • 线程B、线程C加锁失败,进入等待队列
    • 假设线程B 、线程C 同时进入队列
    • 因为队列是个双向链表,队尾进入,所以不可能同时进入队尾
    • 所以,线程B 和 线程C 也以CAS的方式,一个一个进入队尾,如图

 

 

  • 因为ReentrantLock 是可重入锁,所以如果此时线程A继续来加锁的情况
    • 首先,线程A 尝试的 compareAndSetState(0, 1) 会失败
    • 但是,会走到 else 方法里,继续判断这个线程 是否是 当前加锁线程
    • 这个判断会成功,因为 线程A = 线程A
    • 之后会把 state ++,也就是state = 2
    • 释放的时候,也要释放两次,因为只有state = 0 ,别的线程才有可能加锁成功

 

 

  • 然后,ReentranLock 有公平锁 和 非公平锁
    • 公平锁
      • 判断head节点指向的是否为null,如果不为null,说明队列里有线程在等待。
      • 此时会唤醒head指向的线程,随后线程进入for循环,以CAS的方式抢占锁,修改state的值,如果抢到了锁,才把头结点移除
      • 此时没有进入队列的线程,如果想获取锁,首先要判断队列的head是否指向null,只有指向null,才能获取锁。也就是说,只有队列里没有排队线程的时候,其他线程才能获取锁;

 

 

  • 非公平锁
    • 没进入队列的线程(线程D),不管什么情况都可以尝试获取锁。
    • 这个就不画图了,没啥可画的

 

  • Condition
    • 配合Lock 接口的锁使用,代替 wait 和 notify、notifyAll
    • 使用方式:
      • 依赖于lock对象
      • 一个锁,可以有多个Condition
 
  1. // 实例化一个ReentrantLock对象

  2. private ReentrantLock lock = new ReentrantLock();

  3. // 为线程A注册一个Condition

  4. public Condition conditionA = lock.newCondition();

  5. // 为线程B注册一个Condition

  6. public Condition conditionB = lock.newCondition();

 

  • condition 底层也是一个队列,主要的方法有await()、signal()、signalAll()
    • 我们在调用await()方法时,其实就是把线程塞到队列里阻塞
    • 调用singal(),线程被唤醒,继续以CAS的方式尝试获取锁
  • Condition 相对于 Synchronzied的wait和notify的优点
    • 其实就是可以创建多个等待队列了,用Synchronzied的方法加锁,调用wait方法后,所有线程都被放到了加锁对象的monitor的waitSet里了,只有一个waitSet,使用Condition可以创建多个
    • 那如果是多个condition,哪个线程放到哪个condition,怎么放?
      • 首先,condition必须用在lock 和 unlock 之间
      • 也就是说,必须有一个线程获取到锁了,才能使用
      • 以生产者消费者举例
        • 生产者两个线程,消费者两个线程
        • 那生产者肯定只能执行生产者的代码,消费者执行消费者的代码
        • 生产者的线程A,获取到锁后,调用condition1.await(),那线程A就进入这个condition1的等待队列里
        • 消费者的线程B,执行消费者代码,获取到锁后,调用condition2.await(),那线程B就进入condition2的等待队列
        • 消费者如果想唤醒生产者的队列,只要调用condition1.signal() 就好了

 

 

  • ReentrantLock 与 synchronized 的比较
    • 相同点
      • 都是独占锁
      • 都是可重入锁
    • 不同点
      • Synchronzied 的优点
        • Synchronzied 是Java内置关键字,ReentrantLock是API;
          • 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。 Lock 类只是普通的类,JVM 不知道具体哪个线程拥有 Lock 对象
        • Synchronzied 加锁,JVM会有锁升级的过程,不同场景用不同的锁,而且重量级锁也是基于CAS实现的,也有一个entryList和waitSet,性能并不比ReentrantLock差;
        • 自动加锁释放锁,ReentrantLock 不行
      • ReentrantLock 的优点
        • ReentrantLock 可以中断等待获取锁的线程,Synchronzied不行;
        • ReentrantLock 加锁更灵活,有tryLock()
        • ReentrantLock 有Condition,对线程的等待和唤醒更灵活
        • ReentrantLock 支持公平锁

 

 

  • 怎么选择
    • 除非Synchronzied遇到性能瓶颈,或者需要使用ReentrantLock的高级功能,否则使用Synchronzied;
    • 开源项目里很少见到ReentrantLock,读写锁ReentrantReadWriteLock 在开源项目里较多。如果只是普通的加锁,大部分还是Synchronzied,说明性能并不差;
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值