跳至主要內容

JUC2 - 基本介绍2

codejavajuc约 6819 字大约 23 分钟

JUC2

锁类型

在 JDK 1.6 之前,所有的锁都是“重量级”锁,因为使用的是操作系统的互斥锁,当一个线程持有锁时,其他试图进入 synchronized 块的线程将被阻塞,直到锁被释放

因为涉及到了线程上下文切换和用户态与内核态的切换,因此效率低

所以为了减少获得锁和释放锁带来的性能消耗,JDK 1.6引入了"偏向锁"和"轻量级锁"的概念,对 synchronized 做了升级

在 JDK 1.6 以后,一个对象有四种锁状态,级别由低到高是:

  • 无锁状态
  • 偏向锁状态
  • 轻量级锁状态
  • 重量级锁状态

⼏种锁会随着竞争情况逐渐升级,锁的升级很容易发⽣,但是锁降级发⽣的条件就⽐较苛刻了,锁降级发生在 Stop The World 期间,当 JVM 进⼊安全点的时候,会检查是否有闲置的锁,然后进行降级

不同于大部分文章说的锁不能降级,实际上 HotSpot JVM 是支持锁降级的

In its current implementation, monitor deflation is performed during every STW pause, while all Java threads are waiting at a safepoint. We have seen safepoint cleanup stalls up to 200ms on monitor heavy-applications

大致的意思就是重量级锁降级发生于 STW 阶段,降级对象为仅仅能被 VMThread 访问而没有其他 JavaThread 访问的对象

当对象状态为偏向锁时,Mark Word 存储的是偏向的线程 ID;当状态为轻量级锁时, Mark Word 存储的是指向线程栈中 Lock Record 的指针;当状态为重量级时, Mark Word 为指向堆中的 monitor(监视器)对象的指针

重量级锁

重量级锁依赖于操作系统的互斥锁(mutex,⽤于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段)实现

而在操作系统中线程间的状态转换需要相对较长的时间,所以重量级锁的效率低,但被阻塞的线程不会消耗 CPU

当多个线程同时请求某个对象锁时,对象锁会设置不同的状态来区分请求的线程:

  • Contention List: 所有请求锁的线程将被首先放到这个竞争队列里
  • Entry List:Contention List 中那些有资格成为候选⼈的线程被移到 Entry List
  • Wait Set:那些调⽤ wait ⽅法被阻塞的线程被放置到 Wait Set
  • OnDeck:任何时刻最多只能有⼀个线程正在竞争锁,该线程称为 OnDeck
  • Owner:获得锁的线程称为 Owner
  • !Owner:释放锁的线程

对于每一个建立的对象,都有一个monitor与之关联,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的:

ObjectMonitor() {
  _header       = NULL;
  _count        = 0; //记录个数
  _waiters      = 0,
  _recursions   = 0;
  _object       = NULL;
  _owner        = NULL;
  _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
  _WaitSetLock  = 0 ;
  _Responsible  = NULL ;
  _succ         = NULL ;
  _cxq          = NULL ;
  FreeNext      = NULL ;
  _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
  _SpinFreq     = 0 ;
  _SpinClock    = 0 ;
  OwnerIsThread = 0 ;
}

每个等待锁的线程都会被封装成ObjectWaiter对象,进入到如下机制:

alt text
alt text

ObjectWaiter首先会进入 Entry Set 等着,当线程获取到对象的monitor后进入 The Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1

若线程调用wait()方法,将释放当前持有的monitorowner变量恢复为nullcount自减1,同时该线程进入 WaitSet 集合中等待被唤醒

若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取对象的monitor

虽然这样的设计思路非常合理,但是在大多数应用上,每一个线程占用同步代码块的时间并不是很长,我们完全没有必要将竞争中的线程挂起然后又唤醒,并且现代CPU基本都是多核心运行的,我们可以采用一种新的思路来实现锁。

在JDK1.4.2时,引入了自旋锁(JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过无限循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制

在JDK6之后,自旋锁得到了一次优化,自旋的次数限制不再是固定的,而是自适应变化的,比如在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果某个锁经常都自旋失败,那么有可能会不再采用自旋策略,而是直接使用重量级锁。


当⼀个线程尝试获得锁时,如果该锁已经被占⽤,则会将该线程封装成⼀个 ObjectWaiter 对象插入到 Contention List 队列的队首,然后调用 park 方法挂起当前线程

当线程释放锁时,会从 Contention ListEntryList 中挑选⼀个线程唤醒,被选中的线程叫做 Heir presumptive 即假定继承⼈,假定继承⼈被唤醒后会尝试获得锁

synchronized 是非公平的,所以假定继承人不⼀定能获得锁

这是因为对于重量级锁,线程需要先自旋尝试获得锁,这样做的目的是为了减少执行操作系统同步操作带来的开销,如果子旋不成功再进入等待队列,这对那些已经在等待队列中的线程来说,稍微显得不公平

还有一个不公平的地方是子旋线程可能会抢占了 Ready 线程的锁。

如果线程获得锁后调用 Object.wait ⽅法,则会将线程加到 WaitSet 中,当被 Object.notify 唤醒后,会将线程从 WaitSet 移动到 Contention ListEntryList 中去

需要注意的是,当调用⼀个锁对象的 waitnotify 方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁

轻量级锁

从JDK 1.6开始,为了减少获得锁和释放锁带来的性能消耗,就引入了轻量级锁。

轻量级锁的目标是,在无竞争情况下,减少重量级锁产生的性能消耗(并不是为了代替重量级锁,实际上就是赌一手同一时间只有一个线程在占用资源),包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

它不像是重量级锁那样,需要向操作系统申请互斥量。

它的运作机制如下:

JVM 会为每个线程在当前线程的栈帧中创建用于存储锁记录的空间,我们称为 Displaced Mark Word

如果⼀个线程获得锁的时候发现是轻量级锁,会把锁的 Mark Word 复制到自己的 Displaced Mark Word 里面

然后线程尝试用 CAS 将锁的 Mark Word 替换为指向锁记录的指针。

如果成功,当前线程获得锁,如果失败,表示 Mark Word 已经被替换成了其他线程的锁记录,说明在与其它线程竞争锁,当前线程就尝试使用自旋来获取锁。

CAS(Compare And Swap)是一种无锁算法(我们之前在Springboot阶段已经讲解过了),它并不会为对象加锁,而是在执行的时候,看看当前数据的值是不是我们预期的那样,如果是,那就正常进行替换,如果不是,那么就替换失败。

比如有两个线程都需要修改变量i的值,默认为10,现在一个线程要将其修改为20,另一个要修改为30,如果他们都使用CAS算法,那么并不会加锁访问i,而是直接尝试修改i的值,但是在修改时,需要确认i是不是10,如果是,表示其他线程还没对其进行修改,如果不是,那么说明其他线程已经将其修改,此时不能完成修改任务,修改失败。

在CPU中,CAS操作使用的是cmpxchg指令,能够从最底层硬件层面得到效率的提升。

如果CAS操作失败了的话,那么说明可能这时有线程已经进入这个同步代码块

这时虚拟机会再次检查对象的Mark Word,是否指向当前线程的栈帧,如果是,说明不是其他线程,而是当前线程已经有了这个对象的锁,直接放心大胆进同步代码块即可。如果不是,那确实是被其他线程占用了。

这时,轻量级锁一开始的想法就是错的(这时有对象在竞争资源,已经赌输了),所以说只能将锁膨胀为重量级锁,按照重量级锁的操作执行(注意锁的膨胀是不可逆的)

alt text
alt text

所以,轻量级锁 -> 失败 -> 自适应自旋锁 -> 失败 -> 重量级锁

解锁过程同样采用CAS算法,如果对象的MarkWord仍然指向线程的锁记录,那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败,说明其他线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程

偏向锁

偏向锁相比轻量级锁更纯粹,干脆就把整个同步都消除掉,不需要再进行CAS操作了

它的出现主要是得益于人们发现某些情况下某个锁频繁地被同一个线程获取,这种情况下,我们可以对轻量级锁进一步优化。

偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁时,如果接下来都没有其他线程获取此锁,那么持有锁的线程将不再需要进行同步操作。

可以从之前的MarkWord结构中看到,偏向锁也会通过CAS操作记录线程的ID,如果一直都是同一个线程获取此锁,那么完全没有必要在进行额外的CAS操作。当然,如果有其他线程来抢了,那么偏向锁会根据当前状态,决定是否要恢复到未锁定或是膨胀为轻量级锁。

如果我们需要使用偏向锁,可以添加-XX:+UseBiased参数来开启。

所以,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁

值得注意的是,如果对象通过调用hashCode()方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用hashCode()方法,那么会直接将锁升级为重量级锁,并将哈希值存放在monitor(有预留位置保存)中

总结

alt text
alt text
alt text
alt text
锁的升级流程
alt text
alt text

锁消除和锁粗化

锁消除和锁粗化都是在运行时的一些优化方案

比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除。

锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁,这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展。

JMM内存模型

这里提到的内存模型和JVM中介绍的内存模型不在同一个层次,JVM中的内存模型是虚拟机规范对整个内存区域的规划,而Java内存模型,是在JVM内存模型之上的抽象模型,具体实现依然是基于JVM内存模型实现的

Java内存模型

在我们的CPU中,一般都会有高速缓存,而它的出现,是为了解决内存的速度跟不上处理器的处理速度的问题,所以CPU内部会添加一级或多级高速缓存来提高处理器的数据获取效率,但是这样也会导致一个很明显的问题,因为现在基本都是多核心处理器,每个处理器都有一个自己的高速缓存,那么又该怎么去保证每个处理器的高速缓存内容一致呢?

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

而Java也采用了类似的模型来实现支持多线程的内存模型:

alt text
alt text
JMM模型

JMM(Java Memory Model)内存模型规定如下:

  • 所有的变量全部存储在主内存(注意这里包括下面提到的变量,指的都是会出现竞争的变量,包括成员变量、静态变量等,而局部变量这种属于线程私有,不包括在内

  • 每条线程有着自己的工作内存(可以类比CPU的高速缓存)线程对变量的所有操作,必须在工作内存中进行不能直接操作主内存中的数据

  • 不同线程之间的工作内存相互隔离,如果需要在线程之间传递内容,只能通过主内存完成,无法直接访问对方的工作内存

也就是说,每一条线程如果要操作主内存中的数据,那么得先拷贝到自己的工作内存中,并对工作内存中数据的副本进行操作,操作完成之后,也需要从工作副本中将结果拷贝回主内存中,具体的操作就是Save(保存)和Load(加载)操作

具体是怎么实现的呢?

  • 主内存:对应堆中存放对象的实例的部分
  • 工作内存:对应线程的虚拟机栈的部分区域,虚拟机可能会对这部分内存进行优化,将其放在CPU的寄存器或是高速缓存中。比如在访问数组时,由于数组是一段连续的内存空间,所以可以将一部分连续空间放入到CPU高速缓存中,那么之后如果我们顺序读取这个数组,那么大概率会直接缓存命中
例子

前面我们提到,在CPU中可能会遇到缓存不一致的问题,而Java中,也会遇到,比如下面这种情况:

public class Main {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("线程1结束");
        }).start();
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("线程2结束");
        }).start();
        //等上面两个线程结束
        Thread.sleep(1000);
        System.out.println(i);
    }
}

可以看到这里是两个线程同时对变量i各自进行100000次自增操作,但是实际得到的结果并不是我们所期望的那样

因为自增操作 i++ 实际上在编译后不是一条字节码指令完成的

alt text
alt text

包括变量i的获取、修改、保存,都是被拆分为一个一个的操作完成的,那么这个时候就有可能出现在修改完保存之前,另一条线程也保存了,但是当前线程是毫不知情的

重排序

在编译或执行时,为了优化程序的执行效率,编译器或处理器常常会对指令进行重排序,有以下情况:

  1. 编译器重排序:Java编译器通过对Java代码语义的理解,根据优化规则对代码指令进行重排序。
  2. 机器指令级别的重排序:现代处理器很高级,能够自主判断和变更机器指令的执行顺序。

指令重排序能够在不改变结果(单线程)的情况下,优化程序的运行效率,比如:

public static void main(String[] args) {
    int a = 10;
    int b = 20;
    System.out.println(a + b);
}

我们其实可以交换第一行和第二行:

public static void main(String[] args) {
    int b = 10;
    int a = 20;
    System.out.println(a + b);
}

即使发生交换,但是我们程序最后的运行结果是不会变的,当然这里只通过代码的形式演示,实际上JVM在执行字节码指令时也会进行优化,可能两个指令并不会按照原有的顺序进行

虽然单线程下指令重排确实可以起到一定程度的优化作用,但是在多线程下,似乎会导致一些问题:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            if(b == 1) {
                if(a == 0) {
                    System.out.println("A");
                }else {
                    System.out.println("B");
                }   
            }
        }).start();
        new Thread(() -> {
            a = 1;
            b = 1;
        }).start();
    }
}

上面这段代码,在正常情况下,按照我们的正常思维,是不可能输出A的,因为只要b等于1,那么a肯定也是1才对,因为a是在b之前完成的赋值。但是,如果进行了重排序,那么就有可能,a和b的赋值发生交换,b先被赋值为1,而恰巧这个时候,线程1开始判定b是不是1了,这时a还没来得及被赋值为1,可能线程1就已经走到打印那里去了,所以,是有可能输出A的。

volatile关键字

  • 原子性:就是要做什么事情要么做完,要么就不做,不存在做一半的情况
  • 可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
  • 有序性:即程序执行的顺序按照代码的先后顺序执行

如果多线程访问同一个变量,那么这个变量会被线程拷贝到自己的工作内存中进行操作,而不是直接对主内存中的变量本体进行操作

下面这个操作看起来是一个有限循环,但是是无限的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;
    }
}

实际上这就是我们之前说的,虽然我们主线程中修改了a的值,但是另一个线程并不知道a的值发生了改变,所以循环中依然是使用旧值在进行判断,因此,普通变量是不具有可见性的。

要解决这种问题,我们第一个想到的肯定是加锁,同一时间只能有一个线程使用,这样总行了吧,确实,这样的话肯定是可以解决问题的

可以保证可见性

但是,除了硬加一把锁的方案,我们也可以使用volatile关键字来解决,此关键字的第一个作用,就是保证变量的可见性

当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去,并且这个写会操作会导致其他线程中的volatile变量缓存无效

这样,另一个线程修改了这个变量时,当前线程会立即得知,并将工作内存中的变量更新为最新的版本。

那么我们就来试试看:

public class Main {
    //添加volatile关键字
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("线程结束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;
    }
}

结果还真的如我们所说的那样,当a发生改变时,循环立即结束。

无法保证原子性

当然,虽然说volatile能够保证可见性,但是不能保证原子性,要解决我们上面的i++的问题,以我们目前所学的知识,还是只能使用加锁来完成

不对啊,volatile不是能在改变变量的时候其他线程可见吗,那为什么还是不能保证原子性呢?

还是那句话,自增操作是被瓜分为了多个步骤完成的,虽然保证了可见性,但是只要手速够快,依然会出现两个线程同时写同一个值的问题(比如线程1刚刚将a的值更新为100,这时线程2可能也已经执行到更新a的值这条指令了,已经刹不住车了,所以依然会将a的值再更新为一次100)—— 原子类来解决

禁止指令重排

最后一个功能就是volatile会禁止指令重排

也就是说,如果我们操作的是一个volatile变量,它将不会出现重排序的情况,也就解决了我们最上面的问题。

若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  1. 保证特定操作的顺序
  2. 保证某些变量的内存可见性(volatile的内存可见性,其实就是依靠这个实现的)

由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序。

alt text
alt text
屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证Load1的读取操作在Load2及后续读取操作之前执行
StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前,保证Store1的写操作已刷新到主内存
LoadStoreLoad1;LoadStore;Store2在Store2及其后的写操作执行前,保证Load1的读操作已读取结束
StoreLoadStore1;StoreLoad;Load2保证load1的写操作已刷新到主内存之后,load2及其后的读操作才能执行

所以volatile能够保证,之前的指令一定全部执行,之后的指令一定都没有执行,并且前面语句的结果对后面的语句可见。

总结

最后我们来总结一下volatile关键字的三个特性:

  • 保证可见性
  • 不保证原子性
  • 防止指令重排

happens-before原则

JMM提出了happens-before(先行发生)原则,定义一些禁止编译优化的场景,来向各位程序员做一些保证,只要我们是按照原则进行编程,那么就能够保持并发编程的正确性。具体如下:

  • 程序次序规则 同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作

    • 同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求
  • 监视器锁规则 对一个锁的解锁操作,happens-before后续对这个锁的加锁操作

    • 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量x的值修改为了12并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到x是前一个线程修改后的结果12(所以synchronized是有happens-before规则的)
  • volatile变量规则 对一个volatile变量的写操作happens-before后续对这个变量的读操作

    • 就是如果一个线程先去写一个volatile变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见。
  • 线程启动规则: 主线程A启动线程B,线程B中可以看到主线程启动B之前的操作

    • 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  • 线程加入规则: 如果线程A执行操作join()线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。

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

那么我们来从happens-before原则的角度,来解释一下下面的程序结果:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        a = 10;
        b = a + 1;
        new Thread(() -> {
          if(b > 10) System.out.println(a); 
        }).start();
    }
}

首先我们定义以上出现的操作:

  • A: 将变量a的值修改为10
  • B: 将变量b的值修改为a + 1
  • C: 主线程启动了一个新的线程,并在新的线程中获取b,进行判断,如果为true那么就打印a

首先我们来分析,由于是同一个线程,并且B是一个赋值操作且读取了A,那么按照程序次序规则,A happens-before B

接着在B之后,马上执行了C,按照线程启动规则,在新的线程启动之前,当前线程之前的所有操作对新的线程是可见的,所以 B happens-before C

最后根据传递性规则,由于A happens-before B,B happens-before C,所以A happens-before C,因此在新的线程中会输出a修改后的结果10

上次编辑于: