并发编程-synchronized解决原子性问题
文章目录
零、说在前面
有必要为友友们推荐一款极简的原生态AI:阿水AI6,需不需要都点点看看:👇👇👇
https://ai.ashuiai.com/auth/register?inviteCode=XT16BKSO3S
先看看美景养养眼,再继续以下乏味的学习,内容有点多,建议收藏分多次食用。
一、线程安全问题
1.1 什么是线程安全问题
当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也不论这些线程如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。
1.2 自增运算不是线程安全的
使用10个线程并行运行,对一个共享数据进行自增运算,每个线程自增运算1000次,具体代码如下:
public class PlusTest {
final int MAX_TREAD = 10;
final int MAX_TURN = 1000;
CountDownLatch latch = new CountDownLatch(MAX_TREAD);
/**
* 测试用例:测试不安全的累加器
*/
@org.junit.Test
public void testNotSafePlus() throws InterruptedException {
NotSafePlus counter = new NotSafePlus();
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
counter.selfPlus();
}
latch.countDown();
};
for (int i = 0; i < MAX_TREAD; i++) {
new Thread(runnable).start();
}
latch.await();
Print.tcfo("理论结果:" + MAX_TURN * MAX_TREAD);
Print.tcfo("实际结果:" + counter.getAmount());
Print.tcfo("差距是:" + (MAX_TURN * MAX_TREAD - counter.getAmount()));
}
}
public class NotSafePlus {
private Integer amount = 0;
//自增
public void selfPlus() {
amount++;
}
public Integer getAmount() {
return amount;
}
}
运行结果:
[main|PlusTest.testNotSafePlus]:理论结果:10000
[main|PlusTest.testNotSafePlus]:实际结果:3557
[main|PlusTest.testNotSafePlus]:差距是:6443
结果分析:
为什么自增运算不是线程安全的呢?实际上,一个自增运算符是一个复合操作,至少包括三个JVM指令:“内存取值”“寄存器增加1”“存值到内存”。这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。
比如在amount=100时,假设有三个线程同一时间读取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入到amount的内存,amount的结果是101,而不是103。
“内存取值”“寄存器增加1”“存值到内存”这三个JVM指令是不可以再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性。比如先读后写,就有可能在读之后,其实这个变量被修改了,就出现了数据不一致的情况。
1.3 临界区资源与临界区代码段
临界区资源表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。
在并发情况下,临界区资源是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后进行临界区代码段,执行完成之后释放资源。
二、synchronized 关键字的使用
2.1 synchronized 关键字作用
在Java中,线程同步使用最多的方法是使用synchronized关键字。每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)。使用synchronized(syncObject)调用相当于获取syncObject的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。
解决原子性问题
2.2 synchronized 内置锁如何使用
1、修饰方法(同步方法)
synchronized关键字是Java的保留字,当使用synchronized关键字修饰一个方法的时候,该方法被声明为同步方法。在方法声明中设置synchronized同步关键字,保证了其方法的代码执行流程是排他性的。任何时间只允许一条线程进入同步方法(临界区代码段),如果其他线程都需要执行同一个方法,那么只能等待和排队。
//同步方法
public synchronized void selfPlus()
{
amount++;
}
2、修饰代码快(同步代码快)
如果方法中内容太多,还继续使用同步方法,则会影响执行效率。为了执行效率,最好将同步方法分为小的临界区代码段。
将synchronized加在方法上,如果其保护的临界区代码段包含的临界区资源(要求是相互独立的)多于一个,会造成临界区资源的闲置等待,这就会影响临界区代码段的吞吐量。为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。synchronized同步块的写法是:
synchronized(syncObject) //同步块而不是方法
{
//临界区代码段的代码块
}
在synchronized同步块后边的括号中是一个syncObject对象,代表着进入临界区代码段需要获取syncObject对象的监视锁,或者说将syncObject对象监视锁作为临界区代码段的同步锁。由于每一个Java对象都有一把监视锁(Monitor),因此任何Java对象都能作为synchronized的同步锁。单个线程在synchronized同步块后边同步锁后,方能进入临界区代码段;反过来说,当一条线程获得syncObject对象的监视锁后,其他线程就只能等待。
3、同步方法和同步代码块的区别与联系
区别:
synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法;而synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多条线程并发访问的。在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。所以synchronized代码块比synchronized方法更加细粒度地控制了多条线程的同步访问。
联系:
在Java的内部实现上,synchronized方法实际上等同于用一个synchronized代码块,这个代码块包含了同步方法中的所有语句,然后在synchronized代码块的括号中传入this关键字,使用this对象锁作为进入临界区的同步锁。
synchronized方法的同步锁实质上使用了this对象锁,这样就免去了手工设置同步锁的工作。而使用synchronized代码块需要手工设置同步锁。
2.3 synchronized 内置锁分类
1、对象锁-代码块形式:手动指定锁定对象,也可是是this,也可以是自定义的锁
//示例1
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance = new SynchronizedObjectLock();
@Override
public void run() {
// 同步代码块形式——锁为this,两个线程使用的锁是一样的,线程1必须要等到线程0释放了该锁后,才能执行
synchronized (this) {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
// 示例2
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance = new SynchronizedObjectLock();
// 创建2把锁
Object block1 = new Object();
Object block2 = new Object();
@Override
public void run() {
// 这个代码块使用的是第一把锁,当他释放后,后面的代码块由于使用的是第二把锁,因此可以马上执行
synchronized (block1) {
System.out.println("block1锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block1锁,"+Thread.currentThread().getName() + "结束");
}
synchronized (block2) {
System.out.println("block2锁,我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("block2锁,"+Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
2、对象锁-方法锁形式:synchronized修饰普通方法,锁对象默认为this
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
public synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
}
}
3、类锁-synchronize修饰静态方法
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();
@Override
public void run() {
method();
}
// synchronized用在静态方法上,默认的锁就是当前所在的Class类,所以无论是哪个线程访问它,需要的锁都只有一把
public static synchronized void method() {
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
public static void main(String[] args) {
Thread t1 = new Thread(instance1);
Thread t2 = new Thread(instance2);
t1.start();
t2.start();
}
}
4、类锁-synchronized指定锁对象为Class对象
public class SynchronizedObjectLock implements Runnable {
static SynchronizedObjectLock instance1 = new SynchronizedObjectLock();
static SynchronizedObjectLock instance2 = new SynchronizedObjectLock();
@Override
public void run() {
// 所有线程需要的锁都是同一把
synchronized(SynchronizedObjectLock.class){
System.out.println("我是线程" + Thread.currentThread().getName());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "结束");
}
}
public static void main(String[] args) {
Thread t1 = new Thread(instance1);
Thread t2 = new Thread(instance2);
t1.start();
t2.start();
}
}
2.4 synchronized 内置锁释放
使用synchronized块时不必担心监视锁的释放问题,同步代码块正确执行成功之后,监视锁会自动释放;如果程序出现异常,监视锁也会自动释放。
2.5 ynchronized 使用不当带来的死锁
synchronized同步锁虽然能够解决线程安全问题,但是如果使用不当,就会导致死锁,即请求被阻塞一直无法返回。
死锁线程:
两个或者两个以上的线程在执行过程中,由于争夺同一个共享资源造成的相互等待的现象,在没有外部干预的情况下,这些线程将会一直阻塞无法往下执行,这些一直处于相互等待资源的线程就称为死锁线程。
1、死锁案例
定义一个资源类,提供如下两个方法,这两个方法都加了synchronized对象锁。
- saveResource()方法,用于保存资源。
- statisticsResource()方法,用于统计资源数量。
演示死锁代码如下:
两个线程分别访问两个不同的Resource对象,每个resource对象分别调用saveResource()方法保存resource对象的资源,这必然会导致死锁问题。由于两个线程持有自己的对象锁资源,在saveResource()方法中访问对方的statisticsResource()方法并占用对方的锁资源,所以产生互相等待造成死锁的现象。
2、死锁产生的必要条件
不管是线程级别的死锁,还是数据库级别的死锁,只能通过人工干预去解决,所以我们要在写程序的时候提前预防死锁的问题。导致死锁的条件有四个,这四个条件同时满足就会产生死锁。
3、如何解决死锁问题
按照前面说的四个死锁的发生条件,我们只需要破坏其中任意一个,就可以避免死锁的产生。其中,互斥条件我们不可以破坏,因为这是互斥锁的基本约束,其他三个条件都可以破坏。
4、
三、Java 对象结构与内置锁
3.1 Java对象结构
一个Java对象在JVM中的存储结构如下:
3.2 Mark Word 的结构信息
Java对象对象头中的Mark Word存储内容如下:
3.3 无锁、偏向锁、轻量级锁和重量级锁
synchronized的不同锁类型如下:
在Java对象的Mark Word中不同的信息表示不同的锁,具体信息如下:
由biased 和lock位的不同值,分别表示无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。
四、偏向锁的原理与实战
4.1 偏向锁的核心原理
为什么要引入偏向锁?偏向锁的核心原理和核心思想是什么?引入偏向锁有什么缺点?
Java偏向锁是Java6引入的一项多线程优化。顾名思义,它会偏向于第一个访问锁对象的线程,如果同步锁只有一个线程访问,则线程是不需要触发同步的,这种情况下,就会给该线程加一个偏向锁;如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁升级到轻量级锁,然后再唤醒原持有偏向锁的线程。
4.2 偏向锁的演示案例
偏向锁的案例代码如下:
public class InnerLockTest {
final int MAX_TREAD = 10;
final int MAX_TURN = 1000;
CountDownLatch latch = new CountDownLatch(MAX_TREAD);
//偏向锁测试案例
@org.junit.Test
public void showBiasedLock() throws InterruptedException {
Print.tcfo(VM.current().details());
//JVM延迟偏向锁
sleepMilliSeconds(5000);
ObjectLock lock = new ObjectLock();
Print.tcfo("抢占锁前, lock 的状态: ");
lock.printObjectStruct();
sleepMilliSeconds(5000);
CountDownLatch latch = new CountDownLatch(1);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == MAX_TURN / 2) {
Print.tcfo("占有锁, lock 的状态: ");
lock.printObjectStruct();
//读取字符串型输入,阻塞线程
// Print.consoleInput();
}
}
//每一次循环等待10ms
sleepMilliSeconds(10);
}
latch.countDown();
};
new Thread(runnable, "biased-demo-thread").start();
//等待加锁线程执行完成
latch.await();
Print.tcfo("释放锁后, lock 的状态: ");
lock.printObjectStruct();
}
}
4.3 偏向锁获取锁流程
偏向锁的获得锁的逻辑如下:
4.4 偏向锁的撤销和膨胀
假如有多个线程来竞争偏向锁,此对象锁已经有所偏向,其他的线程发现偏向锁并不是偏向自己,就说明存在了竞争,尝试撤销偏向锁(很可能引入安全点),然后膨胀到轻量级锁。
1、偏向锁的撤销
为什么调用Object.hashCode()或者System.identityHashCode()方法计算对象的哈希码之后,偏向锁将撤销?
偏向锁状态下Mark Word内容如下:
因为偏向锁没有存储Mark Word备份信息的地方。换句话说,因为对于一个对象其哈希码只会生成一次并保存在Mark Word中,偏向锁对象的Mark Word已经保存了线程ID,没有地方再保存哈希码时,所以只能撤销偏向锁,将Mark Word用于存放对象的哈希码。
轻量级锁会在帧栈的Lock Record(锁记录)中记录哈希码,重量级锁会在监视器中记录哈希码,起到了对哈希码备份的作用。而偏向锁没有地方备份哈希码,所以只能撤销偏向锁。调用哈希码计算将会使对象再也无法偏向,因为在Mark Word中已经放置了哈希码,偏向锁没有办法放置Thread ID了。调用哈希码计算后,当锁对象可偏向时,Mark Word将变成未锁定状态,并只能升级成轻量级锁;当对象正处于偏向锁时,调用哈希码将使偏向锁撤销后强制升级成重量锁。
偏向锁撤销的过程大致如下
2、偏向锁的膨胀
如果偏向锁被占据,一旦有第二个线程争抢这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到内置锁偏向状态,这时表明在这个对象锁上已经存在竞争了。JVM检查原来持有该对象锁的占有线程是否依然存活,如果挂了,就可以将对象变为无锁状态,然后进行重新偏向,偏向为抢锁线程。
如果JVM检查到原来的线程依然存活,就表明原来的线程还在使用偏执锁,发生锁竞争,撤销原来的偏向锁,将偏向锁膨胀(INFLATING)为轻量级锁。
五、轻量级锁的原理与实战
5.1 轻量级锁的核心原理
为什么引入轻量级锁?轻量级锁的核心原理是什么?
轻量级锁的加锁过程
-
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“ 01 ”状态,是否为偏向锁为“ 0 ”),虚拟机首先将在 当前线程的栈帧中 建立一个名为锁记录( Lock Record )的空间,用于存储锁对象目前的markword的拷贝,官方称之为Displaced Mark Word 。
-
拷贝对象头中的 markword 复制到锁记录中;
-
拷贝成功后,虚拟机将使用CAS操作尝试将对象的 markword 更新为指向LockRecord的指针,并将Lock Record里的 owner指针 指向对象的mark word。如果更新成功,则执行 步骤④ ,否则执行 步骤⑤ 。
-
如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,将对象markword的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示:
-
如果这个更新操作失败了,则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”, markword 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
抢锁线程在通过CAS自旋更新完Mark Word之后,还会做两个善后工作:
- 将含有锁对象信息(如哈希表等)的旧Mard Word值保存在抢锁线程Lock Record的DisplacedMark Word(可以理解为放错地方的Mark Word)字段中,这一步起到备份的作用,以便锁释放之后,将旧的Mark Word值恢复到锁对象头部。
- 抢锁线程将栈帧中的锁记录的owner指针指向锁对象。
锁记录是线程私有的,每个线程有自己的一份锁记录,在创建完锁记录后,会将内置锁对象的Mark Word拷贝到锁记录的Displaced Mark Word字段。这是为什么呢?因为内置锁对象的MarkWord的结构会有所变化,Mark Word将会出现一个指向锁记录的指针,而不再存着无锁状态下的锁对象哈希码等信息,所以必须将这些信息暂存起来,供后面在锁释放时使用。
5.2 轻量级锁的案例演示
轻量级锁演示代码如下
public class InnerLockTest {
final int MAX_TREAD = 10;
final int MAX_TURN = 1000;
CountDownLatch latch = new CountDownLatch(MAX_TREAD);
@org.junit.Test
public void showLightweightLock() throws InterruptedException {
Print.tcfo(VM.current().details());
//JVM延迟偏向锁
sleepMilliSeconds(5000);
ObjectLock lock = new ObjectLock();
Print.tcfo("抢占锁前, lock 的状态: ");
lock.printObjectStruct();
sleepMilliSeconds(5000);
CountDownLatch latch = new CountDownLatch(2);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == 1) {
Print.tcfo("第一个线程占有锁, lock 的状态: ");
lock.printObjectStruct();
}
}
}
//循环完毕
latch.countDown();
//线程虽然释放锁,但是一直存在
for (int j = 0; ; j++) {
//每一次循环等待1ms
sleepMilliSeconds(1);
}
};
new Thread(runnable).start();
sleepMilliSeconds(1000); //等待1s
Runnable lightweightRunnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (lock) {
lock.increase();
if (i == MAX_TURN / 2) {
Print.tcfo("第二个线程占有锁, lock 的状态: ");
lock.printObjectStruct();
}
//每一次循环等待1ms
sleepMilliSeconds(1);
}
}
//循环完毕
latch.countDown();
};
new Thread(lightweightRunnable).start();
//等待加锁线程执行完成
latch.await();
sleepMilliSeconds(2000); //等待2s
Print.tcfo("释放锁后, lock 的状态: ");
lock.printObjectStruct();
}
}
5.3 轻量级锁获取锁流程
轻量级锁获取锁流程如下
轻量级锁的加锁原理如下
5.4 轻量级锁的释放流程
偏向锁也有锁释放的逻辑,但是它只是释放Lock Record,原本的偏向关系仍然存在,所以并不是真正意义上的锁释放。而轻量级锁释放之后,其他线程可以继续使用轻量级锁来抢占锁资源,具体的实现流程如下。
第一步,把Lock Record中_displaced_header存储的lock锁对象的Mark Word替换到lock锁对象的Mark Word中,这个过程会采用CAS来完成。
第二步,如果CAS成功,则轻量级锁释放完成。
第三步,如果CAS失败,说明释放锁的时候发生了竞争,就会触发锁膨胀,完成锁膨胀之后,再调用重量级锁的释放锁方法,完成锁的释放过程。
5.5 轻量级锁和偏向锁的对比
偏向锁,就是在一段时间内只由同一个线程来获得和释放锁,加锁的方式是把Thread Id保存到锁对象的Mark Word中。
轻量级锁,存在锁交替竞争的场景,在同一时刻不会有多个线程同时获得锁,它的实现方式是在每个线程的栈帧中分配一个BasicObjectLock对象(Lock Record),然后把锁对象中的Mark Word拷贝到Lock Record中,最后把锁对象的Mark Word的指针指向Lock Record。轻量级锁之所以这样设计,是因为锁对象在竞争的过程中有可能会发生变化,但是每个线程的Lock Record的Mark Word不会受到影响。因此当触发锁膨胀时,能够通过Lock Record和锁对象的Mark Word进行比较来判定在持有轻量级锁的过程中,锁对象是否被其他线程抢占过,如果有,则需要在轻量级锁释放锁的过程中唤醒被阻塞的其他线程。
六、重量级锁的原理与实战
6.1 重量级锁的核心原理
1、监视器原理
重量级锁原理
监视器特点
2、监视器ObjectMonitor
ObjectMonitor组件介绍
在 Hotspot虚拟 机中,监 视器是由 C++类 ObjectMonitor实现 的,ObjectMonitor类定 义在ObjectMonitor.hpp文件中,其构造器代码大致如下:
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
//线程的重入次数
_recursions = 0;
_object = NULL;
//标识拥有该monitor的线程
_owner = NULL;
//等待线程组成的双向循环链表
_WaitSet = NULL;
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
//多线程竞争锁进入时的单向链表
cxq = NULL ;
FreeNext = NULL ;
//_owner从该双向循环链表中唤醒线程节点
_EntryList = NULL ;
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
3、ObjectMonitor 的内部抢锁过程
内部抢锁流程图如下:
步骤说明:
- JVM每次从队列的尾部取出一个数据用于锁竞争候选者( OnDeck ),但是并发情况下, ContentionList(contention:争论,争夺)会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到 EntryList (entry:进入)中作为候选竞争线程。
- Owner线程会在unlock时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定EntryList中的某个线程为 OnDeck线程 (一般是 最先进去 的那个线程)。
- Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“ 竞争切换 ”。
- OnDeck线程获取到锁资源后会变为 Owner线程 ,而没有得到锁资源的仍然停留在 EntryList 中。如果Owner线程被wait()方法阻塞,则转移到 Waiting Queue 中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去 EntryList 中。 处于 ContentionList 、 EntryList 、 WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
- Synchronized是非公平锁。 Synchronized在线程进入 ContentionList 前,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能 直接抢占 OnDeck线程的锁资源。
4、重量级锁的实现流程
6.2 内核态和用户态
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。
Linux系统的体系架构分为用户态(或者用户空间)和内核态(或者内核空间)。
1、内核态
Linux系统的内核是一组特殊的软件程序,负责控制计 图 2-15 Linux 进程的用户态与内核态算机的硬件资源,例如协调CPU资源、分配内存资源,并且提供稳定的环境供应用程序运行。
2、用户态
应用程序的活动空间为用户空间,应用程序的执行必须依托于内核提供的资源,包括CPU资源、存储资源、I/O资源等。
用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用。
3、用户态内核态切换
用户态与内核态有各自专用的内存空间、专用的寄存器等,进程从用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。
用户态的进程能够访问的资源受到了极大的控制,而运行在内核态的进程可以“为所欲为”。一个进程可以运行在用户态,也可以运行在内核态,那么它们之间肯定存在用户态和内核态切换的过程。进程从用户态到内核态切换主要包括以下三种方式:
- 硬件中断。硬件中断也称为外设中断,当外设完成用户请求时,会向CPU发送中断信号。
- 系统调用。其实系统调用本身就是中断,只不过是软件中断,与硬件中断不同。
- 异常。如果当前进程运行在用户态,这时发生了异常事件(例如缺页异常),就会触发切换。
4、pthread_mutex_lock系统调用
pthread_mutex_lock系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁(Mutex),这是重量级锁开销很大的原因。
6.3 重量级锁的演示案例
重量级锁演示代码如下:
package com.crazymakercircle.innerlock;
import com.crazymakercircle.util.Print;
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.vm.VM;
import java.util.concurrent.CountDownLatch;
import static com.crazymakercircle.util.ThreadUtil.sleepMilliSeconds;
/**
* Created by 尼恩@疯狂创客圈.
*/
public class InnerLockTest {
final int MAX_TREAD = 10;
final int MAX_TURN = 1000;
CountDownLatch latch = new CountDownLatch(MAX_TREAD);
@org.junit.Test
public void showHeavyweightLock() throws InterruptedException {
Print.tcfo(VM.current().details());
//JVM延迟偏向锁
sleepMilliSeconds(5000);
ObjectLock counter = new ObjectLock();
Print.tcfo("抢占锁前, counter 的状态: ");
counter.printObjectStruct();
sleepMilliSeconds(5000);
CountDownLatch latch = new CountDownLatch(3);
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (counter) {
counter.increase();
if (i == 0) {
Print.tcfo("第一个线程占有锁, counter 的状态: ");
counter.printObjectStruct();
}
}
}
//循环完毕
latch.countDown();
//线程虽然释放锁,但是一直存在
for (int j = 0; ; j++) {
//每一次循环等待1ms
sleepMilliSeconds(1);
}
};
new Thread(runnable).start();
sleepMilliSeconds(1000); //等待2s
Runnable lightweightRunnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
synchronized (counter) {
counter.increase();
if (i == 0) {
Print.tcfo("占有锁, counter 的状态: ");
counter.printObjectStruct();
}
//每一次循环等待10ms
sleepMilliSeconds(1);
}
}
//循环完毕
latch.countDown();
};
new Thread(lightweightRunnable, "抢锁线程1").start();
sleepMilliSeconds(100); //等待2s
new Thread(lightweightRunnable, "抢锁线程2").start();
//等待加锁线程执行完成
latch.await();
sleepMilliSeconds(2000); //等待2s
Print.tcfo("释放锁后, counter 的状态: ");
counter.printObjectStruct();
}
}
七、锁升级以及各种锁的对比
7.1 锁升级的实现流程
synchronized锁的升级流程如下:
synchronized执行过程如下:
- 线程抢锁时,JVM首先检测内置锁对象Mark Word中biased_lock(偏向锁标识)是否设置成1,lock(锁标志位)是否为01,如果都满足,确认内置锁对象为可偏向状态。
- 在内置锁对象确认为可偏向状态之后,JVM检查Mark Word中线程ID是否为抢锁线程ID,如果是,就表示抢锁线程处于偏向锁状态,抢锁线程快速获得锁,开始执行临界区代码。
- 如果Mark Word中线程ID并未指向抢锁线程,就通过CAS操作竞争锁。如果竞争成功,就将Mark Word中线程ID设置为抢锁线程,偏向标志位设置为1,锁标志位设置为01,然后执行临界区代码,此时内置锁对象处于偏向锁状态。
- 如果CAS操作竞争失败,就说明发生了竞争,撤销偏向锁,进而升级为轻量级锁。
- JVM使用CAS将锁对象的Mark Word替换为抢锁线程的锁记录指针,如果成功,抢锁线程就获得锁。如果替换失败,就表示其他线程竞争锁,JVM尝试使用CAS自旋替换抢锁线程的锁记录指针,如果自旋成功(抢锁成功),那么锁对象依然处于轻量级锁状态。
- 如果JVM的CAS替换锁记录指针自旋失败,轻量级锁膨胀为重量级锁,后面等待锁的线程也要进入阻塞状态。
总体来说,偏向锁是在没有发生锁争用的情况下使用;一旦有了第二个线程的争用锁,偏向锁就会升级为轻量级锁;如果锁争用很激烈,轻量级锁的CAS自旋到达阈值后,轻量级锁就会升级为重量级锁。
7.2 偏向锁、轻量级锁、重量级锁的对比
偏向锁、轻量级锁、重量级锁三种锁的对比如下:
八、Synchronized与Lock对比
8.1 synchronized的缺陷
1、效率低
锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
2、不够灵活
加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活
3、无法知道是否成功获得锁
相对而言,Lock可以拿到锁状态,而synchronized不能获取到锁状态。
8.2 Lock解决相应问题
详情请看:[并发编程-AbstractQueuedSynchronizer (AQS) 核心原理及应用](#并发编程-AbstractQueuedSynchronizer (AQS) 核心原理及应用)
参考
https://pdai.tech/md/java/thread/java-thread-x-key-synchronized.html
《极致经典(卷2):Java高并发核心编程(卷2 加强版)》 作者:尼恩