1 线程平安问题
在并发编程中,须要解决两个关键问题:线程之间如何 通信 及线程之间如何 同步(这里的线程是指并发执行的流动实体)。
通信 是指线程之间以何种机制来替换信息。Java 中并发采纳的是共享内存模型 ,在共享内存的并发模型里,线程之间共享程序的公共状态,通过写 - 读内存中的公共状态进行 隐式通信。
而 同步 是指程序中用于管制不同线程间操作产生绝对程序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个办法或某段代码须要在线程之间 互斥 执行。
Java 内存模型(Java Memory Model,JMM)形容了 Java 程序中各种变量(线程共享变量)的拜访规定,以及在 JVM 中将变量存储到内存和从内存中读取出变量这样的底层细节。
**
在 Java 中,所有实例域、动态域和数组元素都存储在堆内存中,堆内存在线程之间共享。因为线程的工作内存是线程公有内存,线程间无奈相互拜访对方的工作内存。所以线程 0、线程 1 和线程 2 须要读写主内存的 共享变量
时,就都先将该共享变量拷贝(load)到本人的工作内存,而后在本人的工作内存中对该变量进行所有操作,线程工作内存对 变量正本实现操作之后再将后果同步(save)至主内存。
因而,在线程上下文切换期间,多线程读写共享内存中的全局变量及动态变量容易引发竞态条件。
上面用代码来阐明:
@Slf4j
public class SafeTest {
static int count = 0;
public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 1; i < 5000; i++) {count++;}
});
Thread t2 = new Thread(() -> {for (int i = 1; i < 5000; i++) {count--;}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count 的值是: {}", count);
}
}
依照常理而言,count 最终后果应该为 0。屡次运行发现,最终 count 值还可能是负数,也可能为正数。这是为什么呢?
从字节码的层面进行剖析:
0 iconst_1
1 istore_0
2 iload_0
3 sipush 5000
6 if_icmpge 23 (+17)
9 getstatic #10 <com/kai/demo/basic/SafeTest.count> // 获取动态变量 i 的值
12 iconst_1 // 筹备常量 1
13 iadd // 自增
14 putstatic #10 <com/kai/demo/basic/SafeTest.count> // 将批改后的值存入动态变量 i
17 iinc 0 by 1
20 goto 2 (-18)
23 return
0 iconst_1
1 istore_0
2 iload_0
3 sipush 5000
6 if_icmpge 23 (+17)
9 getstatic #10 <com/kai/demo/basic/SafeTest.count> // 获取动态变量 i 的值
12 iconst_1 // 筹备常量 1
13 isub // 自减
14 putstatic #10 <com/kai/demo/basic/SafeTest.count> // 将批改后的值存入动态变量 i
17 iinc 0 by 1
20 goto 2 (-18)
23 return
可见count++
和 count--
操作理论都是须要这个 4 个指令实现的,那么这里问题就来了!Java 的内存模型如下,实现动态变量的自增、自减须要在主存和工作内存中进行数据交换。
失常程序执行:
理论呈现正数的状况:
理论呈现负数的状况:
像下面 count++
和 count--
的代码所在区域又称 临界区 ( 一段代码内如果存在对共享资源的多线程读写操作,那么称这段代码为临界区)。
跟下面案例一样。如果多个线程临界区代码执行竞争同一资源时,对资源的拜访程序敏感,执行时序的不同导致会呈现某种不失常的行为,就称存在 竞态条件(Race Condition)。
为防止竞态条件的呈现,保障 java 共享内存的原子性、可见性、有序性,很有必要放弃线程同步。Java 中提供了 synchronized、volatile 关键字与 Lock
类。
2 初识 synchronized
synchronized 采纳 互斥同步(Mutual Exclusion & Synchnronization)的形式,让多个线程并发访问共享数据时,保障共享数据在同一时刻只被一个(或一些,应用信号量的时候)线程应用。互斥是实现同步的一种伎俩,临界区、互斥量和信号量都是次要的互斥实现形式。因而在互斥同步四个字中,互斥是因,同步是果;互斥是办法,同步是目标。
2.1 应用场景
在 Java 代码中应用 synchronized 可应用在代码块和办法中,依据 Synchronized 用的地位能够有这些应用场景:
可见,synchronized 的应用场景次要有 3 种:
- 润饰静态方法,给以后类对象加锁,进入同步办法时须要取得类对象的锁;
- 润饰实例办法,给以后实例变量加锁,进入同步办法时须要取得以后实例的锁;
- 润饰同步办法块,指定加锁对象(实例对象 / 是类变量),进入同步办法块时须要取得加锁对象的锁。
2.2 案例剖析
上面,将 synchronized 利用到下面的案例中:
@Slf4j
public class SafeTest {
static int count = 0;
static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {Thread t1 = new Thread(() -> {for (int i = 1; i < 5000; i++) {synchronized (lock){count++;}
}
});
Thread t2 = new Thread(() -> {for (int i = 1; i < 5000; i++) {synchronized (lock){count--;}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count 的值是: {}", count);
}
}
屡次测试发现,后果都为 0。这是因为 synchronized 利用对象锁保障了临界区代码的原子性,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断。
synchronized 为什么这么神奇,它到底做了什么呢?上面就进一步探讨一下 synchronized 的实现原理。
3 synchronized 原理
先来理解两个重要的概念:“Java 对象头”、” 锁记录 ”。
3.1 Java 对象头
对象实例化内存布局与拜访定位中形容了,JVM 中对象内存布局次要分为三块区域:对象头区、实例数据区和填充区。
<img src=”https://img-blog.csdnimg.cn/202010111456447.jpg” style=”zoom: 67%;” />
Synchronized 用到的锁就是存在 Java 对象头里的。对象头区又次要分为两局部,别离是 运行时元数据(Mark Word)和 类型指针。
如果对象是数组类型,则虚拟机用 3 个字宽(Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit。Java 对象头具体构造形容如下:
Mark Word 用于存储对象本身的运行时数据,如:哈希码(HashCode)、GC 分代年龄、锁状态标记、线程持有的锁、偏差线程 ID、偏差工夫戳等。32 位 JVM 的 Mark Word 的默认存储构造如下:
在运行期间,Mark Word 里存储的数据会随着锁标记位的变动而变动。Mark Word 可能变动为存储以下 4 种数据:
在 64 位虚拟机下,Mark Word 是 64bit 大小的,其存储构造如下:
3.2 锁记录
在线程进入同步代码块的时候,如果此 同步对象没有被锁定,它的锁标记位是 01,则虚拟机首先在以后线程的栈中创立咱们称之为 “锁记录(Lock Record)” 的空间,用于存储锁对象的 Mark Word 的拷贝,官网把这个拷贝称为 Displaced Mark Word。整个 Mark Word 及其拷贝至关重要。
Lock Record 是线程公有的数据结构 ,每一个线程都有一个可用 Lock Record 列表,同时还有一个全局的可用列表。 每一个被锁住的对象 Mark Word 都会和一个 Lock Record 关联(对象头的 Mark Word 中的 Lock Word 指向 Lock Record 的起始地址),同时Lock Record 中有一个 Owner 字段寄存领有该锁的线程的惟一标识(或者object mark word
),示意该锁被这个线程占用。
Lock Record | 形容 |
---|---|
Owner | 初始时为 NULL 示意以后没有任何线程领有该 monitor record,当线程胜利领有该锁后保留线程惟一标识,当锁被开释时又设置为 NULL。 |
EntryQ | 关联一个零碎互斥锁(semaphore),阻塞所有试图锁住 monitor record 失败的线程。 |
RcThis | 示意 blocked 或 waiting 在该 monitor record 上的所有线程的个数。 |
Nest | 用来实现重入锁的计数。 |
HashCode | 保留从对象头拷贝过去的 HashCode 值(可能还蕴含 GC age)。 |
Candidate | 用来防止不必要的阻塞或期待线程唤醒,因为每一次只有一个线程可能胜利领有锁,如果每次前一个开释锁的线程唤醒所有正在阻塞或期待的线程,会引起不必要的上下文切换(从阻塞到就绪而后因为竞争锁失败又被阻塞)从而导致性能重大降落。Candidate 只有两种可能的值 0 示意没有须要唤醒的线程 1 示意要唤醒一个继任线程来竞争锁 |
3.3 Monitor 原理
Monitor,常被翻译为“监视器”或者“管程”。
操作系统在面对过程 / 线程间同步时,所反对的最重要的同步原语即是 semaphore 信号量 和 mutex 互斥量。在应用根本的 mutex 进行并发管制时,须要程序员十分小心地管制 mutex 的 down 和 up 操作,否则很容易引起死锁等问题。为了更容易地编写出正确的并发程序,在 mutex 和 semaphore 的根底上,提出了更高层次的同步原语 Monitor。
不过须要留神的是,操作系统自身并不反对 Monitor 机制,Monitor 是属于编程语言的领域。例如 C 语言它就不反对 monitor,Java 语言反对 Monitor。Java 对象则是天生的 Monitor,每一个 Java 对象都有成为 Monitor 的“潜质”。这是为什么呢?
因为在 Java 的设计中,每一个对象自打娘胎里进去,就带了一把看不见的锁,通常咱们叫 “外部锁”,或者“Monitor 锁”,或者“Intrinsic lock”。有了这个锁的帮忙, 只有把类的对象办法或者代码块用 synchronized 关键字润饰,就会先获取到与 synchronized 关键字绑定在一起的 Object 的对象锁,这个锁会限定其它线程进入与这个锁相干的 synchronized 代码区域。而这个对象锁,也就是一个货真价实的 Monitor。
因而,能够把 Monitor 了解为一种同步工具,也能够了解是一种同步机制,它通常被形容为一个对象。其次要特点有:
- 对象的所有办法都被“互斥”的执行。也就是说,同一个时刻,只有一个 过程 / 线程 能进入 Monitor 中定义的临界区。
- 通常提供 singal 机制。即容许正持有“许可”的线程临时放弃“许可”,期待某个谓词成真(条件变量),而条件成立后,以后过程能够“告诉”正在期待这个条件变量的线程,让他能够从新去取得运行许可。
应用 synchronized
给对象上锁时,该对象头的 Mark Word 中就被设置为指向 Monitor 对象的指针。Mark Word 锁标识位为 10,其中指针指向的是 Monitor 对象的起始地址。在 Java 虚拟机(HotSpot)中,Monitor 是由 ObjectMonitor 实现的,其次要数据结构如下(位于 HotSpot 虚拟机源码 objectMonitor.hpp 文件,C++ 实现的):
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
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 ;
_previous_owner_tid = 0;
}
ObjectMonitor 中有两个队列,_WaitSet 和 _EntryList,用来保留 ObjectWaiter 对象列表(每个期待锁的线程都会被封装成 ObjectWaiter 对象),_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时拜访一段同步代码:
- 线程须要获取 Object 的锁时,会被放入 EntrySet(入口区)中进行期待(enter)。
- 如果该线程获取到了锁(acquire),成为以后锁的 Owner。
- 如果依据程序逻辑,一个曾经取得了锁的线程短少某些内部条件,而无奈持续进行上来(例如生产者发现队列已满或者消费者发现队列为空),那么该线程能够通过调用 wait 办法将锁开释(release),进入 Wait Set(期待区)中阻塞(BLOCKED)进行期待。
- 其它线程在这个时候有机会取得锁,从而使得之前不成立的内部条件成立,这样先前被阻塞的线程就能够从新进入 EntrySet 去竞争锁(acquire)。这个内部条件在 Monitor 机制中称为 条件变量。
Tips:因为进入期待区只有号入口,由此能够推断,一个线程只有在持有监视器时能力执行 wait 操作,处于期待的线程只有再次取得监视器能力退出期待状态。
上面再从字节码角度了解一下 Monitor 原理:
public class Test {
static int counter = 0;
static final Object lock = new Object();
public static void main(String[] args) {synchronized (lock) {counter++;}
}
}
应用 javap 命令反编译 class 文件:javap -v Test.class
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0 getstatic #2 <com/kai/demo/basic/Test.lock> // <- 获得 lock 的援用(synchronized 开始)3 dup // 复制了一份 lock 长期援用
4 astore_1 // lock 长期援用 -> 存入局部变量表 slot 1 中
5 monitorenter // 将 lock 对象的 Mark Word 置为指向 Monitor 指针
6 getstatic #3 <com/kai/demo/basic/Test.counter> // <- i
9 iconst_1 // 筹备常数 1
10 iadd // +1
11 putstatic #3 <com/kai/demo/basic/Test.counter> // -> i
14 aload_1 // <- 获得 lock 长期援用,放入操作数栈栈顶
15 monitorexit // 将 lock 对象的 Mark Word 重置,唤醒 EntryList
16 goto 24 (+8) // 执行到 24 行,代码完结
// 上面是异样解决指令。可见,如果出现异常,也能主动地开释锁。19 astore_2 // exception -> slot 2
20 aload_1 // <- 获得 lock 的援用
21 monitorexit // 将 lock 对象的 Mark Word 重置,唤醒 EntryList
22 aload_2 // <- slot 2(exception)
23 athrow // throw(exception)
24 return
Exception table:
from to target type
6 16 19 any // 异样检测 6 -16 行代码(即长期区)19 22 19 any
LineNumberTable:
line 22: 0
line 23: 6
line 24: 14
line 25: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
--- omit ---
执行同步代码块后首先要先执行 monitorenter 指令,退出的时候 monitorexit 指令。
通过剖析之后能够看出,应用 Synchronized 进行同步,其要害就是必须要对对象的监视器 monitor 进行获取,当线程获取 monitor 后能力持续往下执行,否则就只能期待。而这个获取的过程是 互斥 的,即同一时刻只有一个线程可能获取到 monitor。过程大抵如下:
- 如果 monitor 的进入数为 0,则该线程进入 monitor,而后将进入数设置为 1,该线程即为 monitor 的所有者;
- 如果线程曾经占有该 monitor,如果从新进入,则进入 monitor 的进入数加 1(锁的重入性);
- 如果其余线程曾经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再从新尝试获取 monitor 的所有权。
monitorexit 指令执行时,monitor 的进入数减 1,如果减 1 后进入数为 0,那线程退出 monitor,不再是这个 monitor 的所有者。其余被这个 monitor 阻塞的线程能够尝试去获取这个 monitor 的所有权。
下面案例中,monitorexit 指令呈现了两次,第 1 次为同步失常退出开释锁,第 2 次为产生异步退出开释锁。
通过下面两段形容,咱们应该能很分明的看出 Synchronized 的实现原理,Synchronized 的语义底层是通过一个 monitor 的对象来实现。
须要留神的是,synchronized 用在同步办法上时,字节码指令中不会呈现 monitorenter 和 monitorexit 指令。
例如:
public class Test1 {public synchronized void method() {System.out.println("Hello World!");
}
}
应用 javap 命令反编译 class 文件:javap -v Test1.class
public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 11: 0
line 12: 8
--- omit ---
办法的同步并没有通过指令 monitorenter
和 monitorexit
来实现,不过绝对于一般办法,其常量池中多了 ACC_SYNCHRONIZED
标示符。JVM 就是依据该标示符来实现办法的同步的:
当办法调用时,调用指令将会查看办法的 ACC_SYNCHRONIZED 拜访标记是否被设置 ,如果设置了, 执行线程将先获取 monitor,获取胜利之后能力执行办法体,办法执行完后再开释 monitor。在办法执行期间,其余任何线程都无奈再取得同一个 monitor 对象。
两种同步形式实质上没有区别,只是办法的同步是一种隐式的形式来实现,无需通过字节码来实现。两个指令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、期待从新调度,会导致“用户态和内核态”两个态之间来回切换。
4 synchronized 进阶
4.1 重量级锁
在 JDK 6 之前,synchronized 通过监视器(Monitor)来实现线程同步,然而 Monitor 实质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统要实现线程之间的切换须要从用户态转换到外围态,转换工夫绝对比拟长,老本也十分高。因而,起初称这种锁为“重量级锁”。
JDK 6 为了缩小取得锁和开释锁带来的性能耗费,引入了“偏差锁”和“轻量级锁”。所以,目前锁一共有 4 种状态,级别从低到高顺次是:无锁状态、偏差锁状态、轻量级锁状态和重量级锁状态 ,这几个状态会随着竞争状况逐步降级。 锁能够降级但不能降级,意味着偏差锁升级成轻量级锁后不能降级成偏差锁。这种锁降级却不能降级的策略,目标是为了进步取得锁和开释锁的效率。
四种锁状态对应的的 Mark Word 内容形容如下:
在 64 位虚拟机下,Mark Word 在不同锁状态存储构造如下:
4.2 自旋
4.2.1 自旋锁
线程的阻塞和唤醒须要 CPU 从用户态切转为外围态,而且这种切换不易优化。如果锁的粒度很小,即锁持有的工夫很短的时候。由锁竞争造成频繁地阻塞和唤醒线程就显得十分不值得,因而引入了自旋锁。
自旋锁能够缩小线程阻塞造成的线程切换。其执行步骤如下:
- 以后线程尝试去竞争锁。
- 竞争失败,筹备阻塞本人。
- 然而并没有阻塞本人,进入自旋状态(空期待,比方一个空的无限 for 循环)。
- 自旋状态下,持续竞争锁。
- 如果自旋期间胜利获取锁,那么完结自旋状态,否则进入阻塞状态。
如果在自旋期间胜利获取锁,那么就缩小一次线程的切换。
可见,如果持有锁的线程很快就开释了锁,那么自旋的效率就十分好,反之,自旋的线程就会白白消耗掉解决的资源。所以自旋锁适宜在持有锁工夫短,并且竞争强烈的场景下应用。
在 JDK1.6 中自旋锁默认开启。能够应用 -XX:+UseSpinning
开启,-XX:-UseSpinning
敞开自旋锁优化。
自旋的默认次数为 10 次,能够应用-XX:preBlockSpin
参数批改默认的自旋次数。
4.2.2 适应性自旋锁
适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋 10 次。他能够依据它后面线程的自旋状况,从而调整它的自旋。
例如,线程总是自旋胜利,那么虚拟机就会容许自旋期待继续的次数更多。反之,如果对于某个锁,很少有自旋可能胜利,那么在当前竞争这个锁的时,自旋的次数会缩小甚至省略掉自旋过程间接进入阻塞状态,免得节约处理器资源。
4.3 锁打消与锁粗化
4.3.1 锁打消
JVM 会对不会存在线程平安的锁进行锁打消。例如应用 JDK 的内置 API,如 StringBuffer、Vector、HashTable 等会存在隐形的加锁操作。
public void vectorTest(){Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){vector.add(i + "");
}
System.out.println(vector);
}
运行这段代码时,JVM 显著检测到变量 vector 没有逃逸出办法 vectorTest()之外,所以 JVM 会大胆地将 vector 外部的加锁操作打消。
锁打消的根据是逃逸剖析的数据反对。
4.3.2 锁粗化
在遇到一连串地对同一锁一直进行申请和开释的操作时,JVM 会把所有的锁操作整合成锁的一次申请,从而缩小对锁的申请同步次数,这个操作叫做锁的粗化。
例如:
for(int i = 0 ; i < 100 ; i++){synchronized(lock){// 同步块}
}
锁粗化后:
synchronized(lock){for(int i = 0 ; i < 100 ; i++){// 同步块}
}
4.4 偏差锁
在大多数状况下,锁不仅不存在多线程竞争,而且总是由同一线程屡次取得,为了缩小此类情况下线程取得锁的性能耗费,JDK6 中引进了偏差锁。
当一个线程拜访同步代码块并获取锁时,会在 Mark Word 里存储锁偏差的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向以后线程的偏差锁 。引入偏差锁是为了在没有多线程竞争的状况下尽量减少不必要的轻量级锁执行门路, 偏差锁只须要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。
偏差锁只有遇到其余线程尝试竞争偏差锁时,持有偏差锁的线程才会开释锁,线程不会被动开释偏差锁。偏差锁的撤销,须要期待 全局平安点(在这个工夫点上没有字节码正在执行),它会首先暂停领有偏差锁的线程,判断锁对象是否处于被锁定状态。撤销偏差锁后复原到无锁(标记位为“01”)或轻量级锁(标记位为“00”)的状态。
偏差锁在 JDK 6 及当前的 JVM 里是默认启用的。能够通过 JVM 参数敞开偏差锁:-XX:-UseBiasedLocking=false
,敞开之后程序默认会进入轻量级锁状态。
上面用 OpenJDK 的 JOL 包来做试验,先增加 maven 依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
定义一个一般的 Java 对象:
public class Person {
String str = "";
Son son = new Son();}
class Son {}
利用 JOL 包下的 ClassLayout 来输入他的内存布局:
@Slf4j
public class BiasedLockTest {public static void main(String[] args) {Person person = new Person();
log.debug(ClassLayout.parseInstance(person).toPrintable());
}
}
打印后果如下:
图中的 1、2、3、4 别离对应 MarkWord、类型指针、实例数据、对齐填充。
- MarkWord:共 8 字节,该对象刚新建,锁标识位是 01,处于无锁状态。
- 类型指针:共 4 字节,标识新建的 person 对象。
- 实例数据:共 8 字节,定义的 Person 有两个属性,str 和 son 对应的类型别离为 String 和 Son,这两个属性各占 4 个字节。
- 对齐填充:共 4 个字节,前三个局部字节相加为 8 + 4+ 8 = 20,不是 8 的整数倍,所以得填充 4 个字节凑齐 24 字节。形容信息中也有阐明:
loss due to the next object aligment
。
下面提到,偏差锁在 JDK 6 及当前的 JVM 里是默认启用的。那为什么启动后标记地位 是“001”无锁状态而不是“101”偏差锁状态呢 ?这是因为 偏差锁默认是提早加载的,不会在程序启动的时候立即失效,能够通过 JVM 参数来防止提早:-XX:BiasedLockingStartupDelay=0
。
再次运行:
4.5 轻量级锁
偏差锁多利用只有一个线程拜访同步块场景中,一旦偏差锁被其余线程拜访,就会降级为轻量级锁。其余线程会通过自旋的模式尝试获取锁,不会阻塞,从而进步性能。
应用轻量级锁的多线程之间不存在锁竞争,线程是交替执行同步块的 。引入轻量级锁的目标正是 在没有多线程竞争的前提下,缩小传统的重量级锁应用操作系统互斥量产生的性能耗费。
轻量级锁加锁过程如下:
- 在代码块进入同步块时,如果同步对象锁状态为无锁状态(锁标记位 01,是否偏差锁 0),虚拟机首先将在以后线程的栈帧中建设一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官网称之为 Displaced Mark Word。
- 拷贝对象头中的 Mark Word 复制到锁记录中。
- 拷贝胜利后,虚拟机将应用 CAS 操作 尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。
- 如果更新动作胜利了,那么这个线程就领有了该对象的锁,此时对象 Mark Word 锁标记位设置为 00,示意此对象处于轻量级锁定状态。
- 如果更新操作失败了,虚拟机首先会查看对象的 Mark Word 是否指向以后线程的栈帧,如果是,阐明以后线程曾经领有了这个对象的锁,那么可用间接进入同步块继续执行。否则阐明有多个线程竞争锁,若以后只有一个期待线程,则线程会通过自旋进行期待;但当自旋超过肯定次数或者一个线程持有锁,一个在自旋,又来了第三个线程竞争锁,那么轻量级锁会收缩降级为重量级锁,锁标记位设置为 10。
4.6 锁收缩 / 锁降级
锁降级过程:无锁—> 偏差锁—> 轻量级锁—> 重量级锁。具体如下:
5 简析 CAS
CAS 全称 Compare And Swap(比拟与替换),是一种无锁算法。在不应用锁(没有线程被阻塞)的状况下实现多线程之间的 变量同步。
CAS 算法波及到三个操作数:
- V 内存地址寄存的理论值
- A 比拟的旧值
- B 更新的新值
当且仅当 V 的值等于 A 时(旧值和内存中理论的值雷同),表明旧值 A 曾经是目前最新版本的值,自然而然能够将新值 N 赋值给 V。反之则表明 V 和 A 变量不同步,间接返回 V 即可。当多个线程应用 CAS 操作一个变量时,只有一个线程会更新胜利,其余失败的线程会从新尝试。也就是说,“更新”是一个一直重试的操作。
进入原子类 AtomicInteger 的源码,看一下 AtomicInteger 的定义:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// 获取并操作内存的数据。private static final Unsafe unsafe = Unsafe.getUnsafe();
// 存储 value 在 AtomicInteger 中的偏移量。private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) {throw new Error(ex); }
}
// 存储 AtomicInteger 的 int 值,该属性须要借助 volatile 关键字保障其在线程间是可见的。private volatile int value;
接下来,咱们查看 AtomicInteger 的自增函数 incrementAndGet()的源码时,发现自增函数底层调用的是 unsafe.getAndAddInt()。
// AtomicInteger 自增办法
public final int incrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
compareAndSwapInt
这个函数,它也是 CAS
缩写的由来。通过 OpenJDK 8 来查看 Unsafe.cpp 的源码:
// Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
依据 OpenJDK 8 的源码咱们能够看出,getAndAddInt()循环获取给定对象 o 中的偏移量处的值 v,而后判断内存值是否等于 v。如果相等则将内存值设置为 v + delta,否则返回 false,持续循环进行重试,直到设置胜利能力退出循环,并且将旧值返回。整个“比拟 + 更新”操作封装在 compareAndSwapInt()中,在 JNI 里是借助于一个 CPU 指令实现的,属于原子操作,能够保障多个线程都可能看到同一个变量的批改值。
后续 JDK 通过 CPU 的cmpxchg 指令,去比拟寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。而后通过 Java 代码中的 while 循环再次调用 cmpxchg 指令进行重试,直到设置胜利为止。
6 写在最初
在并行编程过程中,很容易产生线程平安问题,比方多线程读写共享内存中的全局变量及动态变量时引发的竞态条件。
咱们能够应用 synchronized 关键字来放弃线程同步,防止上述问题产生。而 synchronized 是基于 Monitor 机制实现的,然而 Monitor 实质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统要实现线程之间的切换须要从用户态转换到外围态,转换工夫绝对比拟长,老本也十分高。因而,起初称这种锁为“重量级锁”。
JDK 6 为了缩小取得锁和开释锁带来的性能耗费,引入了“偏差锁”和“轻量级锁”。所以,目前锁一共有 4 种状态,级别从低到高顺次是:无锁状态、偏差锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争状况逐步降级,性能开销也逐步增大。这 4 种锁并不是互相代替的关系,它们只是在不同场景下的不同抉择。
锁 | 长处 | 毛病 | 实用场景 |
---|---|---|---|
偏差锁 | 加锁和解锁不须要额定耗费, 和执行非同步办法相比仅仅存在纳秒级的差距。 |
线程间存在锁竞争, 会带来额定的锁撤销的耗费。 |
实用于只有一个线程拜访同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞, 进步了线程的响应速度。 |
如果始终得不到锁竞争的线程,<br/> 应用自旋会耗费 CPU。 | 谋求响应速度,<br/> 同步块执行速度十分快。 |
重量级锁 | 线程竞争不会应用自旋,<br/> 不会耗费 CPU。 | 线程阻塞,响应工夫迟缓。 | 谋求吞吐量,<br/> 同步块执行工夫较长。 |
参考资料
【JAVA 学习笔记】多线程
JAVA 并发编程的艺术
让你彻底了解 Synchronized
Java 中的 Monitor 机制
深入分析 Synchronized 原理
不可不说的 Java“锁”事
对象的内存布局(JOL)和锁