JUC详解

132 阅读13分钟

什么是JMM

首先我们要了解CPU的内存模型,我们都知道CPU缓存是为了解决CPU处理速度和内存出库速度不对等的问题,常见的CPU内存模型分为三层,分别叫做L1,L2,L3 Cache,CPU缓存的工作方式是,先复制一份数据到CPU缓存中,当CPU用到的时候就可以直接从CPU缓存中获取,当运算完成后再同步回主内存,但是这样就存在缓存一致性问题,比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3,为了解决这个问题可以通过缓存一致性协议解决,缓存一致性协议就是CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。

image.png 那么什么是JMM,JMM屏蔽了不同硬件和操作系统内存的访问差异,保证了Java程序在不同的平台下达到一致的内存访问效果,同时也保证在高效并发的时候程序能够正确执行。

image.png

JMM的作用

Java的内存模型围绕三个特征建立起来,分别有原子性、可见性、有序性。

  • 原子性

    原子性是指一个操作不可分割不可中断,不能被其他线程干扰,JMM只能保证最基本的原子性,要保证一个代码块的原子性提供了synchronized以及各种 Lock 以及各种原子类实现原子性。

  • 可见性

    可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java是利用volatile关键字来提供可见性的。 当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。除了volatile关键字之外,final和synchronized也能实现可见性。synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。

  • 可见性

    由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序(指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致),在Java中,可以使用synchronized或者volatile保证多线程之间操作的有序性。实现原理有些区别volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。

happens-before原则

happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

image.png happens-before原则的定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。

  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。

happens-before常见规则:

  • 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;

  • 解锁规则:解锁 happens-before 于加锁;

  • volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。

  • 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;

  • 线程启动规则:Thread 对象的 start()方法 happens-before 于此线程的每一个动作。

Volatile

volatile的主要作用包括两点:

  1. 保证线程间变量的可见性

    在改变一个共享变量时,首先将共享变量同步回主内存,然后失效其他线程的变量副本,当其他线程再用到这个共享变量时从主内存读取新值。

  2. 禁止CPU进行指令重排序

    首先要讲一下as-if-serial语义,不管怎么重排序,(单线程)程序的执行结果不能被改变。 重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:

image.png 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。volatile关键字禁止指令重排序有两层意思:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

内存屏障:

  • LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • 对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

synchronized

在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的。synchronized关键字可以实现悲观锁、非公平锁、可重入锁、独占锁或者排他锁。 synchronized主要有三种使用方式:修饰普通同步方法、修饰静态同步方法、修饰同步方法块。

  • 普通方法
synchronized void method() {
    //业务代码
}
  • 静态方法
synchronized static void method() {
    //业务代码
}
  • 代码块
synchronized(this) {
    //业务代码
}

synchronized的底层实现原理:synchronized 同步语句块的实现使用的是 monitorentermonitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置,当执行 monitorenter 指令时,线程试图获取锁也就是获取 对象监视器 monitor 的持有权,在执行monitorenter时,会尝试获取对象的锁,如果锁的计数器为 0 则表示锁可以被获取,获取后将锁计数器设为 1 也就是加 1。对象锁的的拥有者线程才可以执行 monitorexit 指令来释放锁。在执行 monitorexit 指令后,将锁计数器设为 0,表明锁被释放,其他线程可以尝试获取锁。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

锁升级

先了解下对象头结构

image.png 在jdk1.5版本(包含)之前,锁的状态只有两种状态:“无锁状态”和“重量级锁状态”,只要有线程访问共享资源对象,则锁直接成为重量级锁,jdk1.6版本后,对synchronized锁进行了优化,新加了“偏向锁”和“轻量级锁”,用来减少上下文的切换以提高性能,所以锁就有了4种状态。

  1. 无锁

对于共享资源,不涉及多线程的竞争访问。

  1. 偏向锁

共享资源首次被访问时,JVM会对该共享资源对象做一些设置,比如将对象头中是否偏向锁标志位置为1,对象头中的线程ID设置为当前线程ID(注意:这里是操作系统的线程ID),后续当前线程再次访问这个共享资源时,会根据偏向锁标识跟线程ID进行比对是否相同,比对成功则直接获取到锁,进入临界区域(就是被锁保护,线程间只能串行访问的代码),这也是synchronized锁的可重入功能。

  1. 轻量级锁

当多个线程同时申请共享资源锁的访问时,这就产生了竞争,JVM会先尝试使用轻量级锁,以CAS方式来获取锁(一般就是自旋加锁,不阻塞线程采用循环等待的方式),成功则获取到锁,状态为轻量级锁,失败(达到一定的自旋次数还未成功)则锁升级到重量级锁。

  1. 重量级锁

如果共享资源锁已经被某个线程持有,此时是偏向锁状态,未释放锁前,再有其他线程来竞争时,则会升级到重量级锁,另外轻量级锁状态多线程竞争锁时,也会升级到重量级锁,重量级锁由操作系统来实现,所以性能消耗相对较高。
这4种级别的锁,在获取时性能消耗:重量级锁 > 轻量级锁 > 偏向锁 > 无锁。

当一个共享资源首次被某个线程访问时,锁就会从无锁状态升级到偏向锁状态,偏向锁会在Markword的偏向线程ID里存储当前线程的操作系统线程ID,偏向锁标识位是1,锁标识位是01。

此后如果当前线程再次进入临界区域时,只比较这个偏向线程ID即可,这种情况是在只有一个线程访问的情况下,不再需要操作系统的重量级锁来切换上下文,提供程序的访问效率。

如果未开启偏向锁(或者在JVM偏向锁延迟时间之前),有线程访问共享资源则直接由无锁升级为轻量级锁,开启偏向线程锁后,并且当前共享资源锁已经是偏向锁时,再有第二个线程访问共享资源锁时,此时锁可能升级为轻量级锁,也可能还是偏向锁状态,因为这取决于线程间的竞争情况,如有没有竞争,那么偏向锁的效率更高(因为频繁的锁竞争会导致偏向锁的撤销和升级到轻量级锁),继续保持偏向锁。

如果有竞争,则锁状态会从偏向锁升级到轻量级锁,这种情况下轻量级锁效率会更高。当第二个线程尝试获取偏向锁失败时,偏向锁会升级为轻量级锁,此时,JVM会使用CAS自旋操作来尝试获取锁,如果成功则进入临界区域,否则升级为重量级锁。

轻量级锁是在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,尝试拷贝锁对象头的Markword到栈帧的Lock Record,若拷贝成功,JVM将使用CAS操作尝试将对象头的Markword更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象头的Markword。若拷贝失败,若当前只有一个等待线程,则可通过自旋继续尝试, 当自旋超过一定的次数,或者一个线程在持有锁,一个线程在自旋,又有第三个线程来访问时,轻量级锁就会膨胀为重量级锁。

当轻量级锁获取锁失败时,说明有竞争存在,轻量级锁会升级为重量级锁,此时,JVM会将线程阻塞,直到获取到锁后才能进入临界区域,底层是通过操作系统的mutex lock来实现的,每个对象指向一个monitor对象,这个monitor对象在堆中与锁是关联的,通过monitorenter指令插入到同步代码块在编译后的开始位置,monitorexit指令插入到同步代码块的结束处和异常处,这两个指令配对出现。

JVM的线程和操作系统的线程是对应的,重量级锁的Markword里存储的指针是这个monitor对象的地址,操作系统来控制内核态中的线程的阻塞和恢复,从而达到JVM线程的阻塞和恢复,涉及内核态和用户态的切换,影响性能,所以叫重量级锁。

ReentrantLock

ReentrantLock 实现了 Lock 接口,是一个可重入且独占式的锁,和 synchronized 关键字类似。不过,ReentrantLock 更灵活、更强大,增加了轮询、超时、中断、公平锁和非公平锁等高级功能。 ReentrantLock 里面有一个内部类 SyncSync 继承 AQS(AbstractQueuedSynchronizer),添加锁和释放锁的大部分操作实际上都是在 Sync 中实现的。Sync 有公平锁 FairSync 和非公平锁 NonfairSync 两个子类。

公平锁和非公平锁的区别

  • 公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。
  • 非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

ThreadLocal

ThreadLocal类主要解决的就是让每个线程绑定自己的值。

实现原理:Thread 类中有一个 threadLocals 和 一个 inheritableThreadLocals 变量,它们都是 ThreadLocalMap 类型的变量,我们可以把 ThreadLocalMap 理解为ThreadLocal 类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用 ThreadLocal 类的 setget方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap类对应的 get()set()方法。

最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。 ThrealLocal 类中可以通过Thread.currentThread()获取到当前线程对象后,直接通过getMap(Thread t)可以访问到该线程的ThreadLocalMap对象。

每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 key ,Object 对象为 value 的键值对。