1. 讲一下synchronized关键字的底层原理?
1.1. 基本使用
如下抢票的代码,如果不加锁,就会出现超卖或者一张票卖给多个人
Synchronized
【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住
public class TicketDemo {static Object lock = new Object();int ticketNum = 10;public synchronized void getTicket() {synchronized (this) {if (ticketNum <= 0) {return;}System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);// 非原子性操作ticketNum--;}}public static void main(String[] args) {TicketDemo ticketDemo = new TicketDemo();for (int i = 0; i < 20; i++) {new Thread(() -> {ticketDemo.getTicket();}).start();}}}
1.2. Monitor
Monitor
被翻译为监视器,是由jvm
提供,c++
语言实现
在代码中想要体现monitor
需要借助javap
命令查看clsss
的字节码,比如以下代码:
public class SyncTest {static final Object lock = new Object();static int counter = 0;public static void main(String[] args) {synchronized (lock) {counter++;}}
}
找到这个类的class
文件,在class
文件目录下执行javap -v SyncTest.class
,反编译效果如下:
monitorenter
上锁开始的地方monitorexit
解锁的地方- 其中被
monitorenter
和monitorexit
包围住的指令就是上锁的代码- 有两个
monitorexit
的原因,第二个monitorexit
是为了防止锁住的代码抛异常后不能及时释放锁
在使用了synchornized
代码块时需要指定一个对象,所以synchornized
也被称为对象锁
monitor
主要就是跟这个对象产生关联,如下图
Monitor
内部具体的存储结构:
-
Owner
:存储当前获取锁的线程的,只能有一个线程可以获取 -
EntryList
:关联没有抢到锁的线程,处于Blocked
状态的线程 -
WaitSet
:关联调用了wait方法的线程,处于Waiting
状态的线程
具体的流程:
- 代码进入
synchorized
代码块,先让lock
(对象锁)关联的monitor
,然后判断Owner
是否有线程持有 - 如果没有线程持有,则让当前线程持有,表示该线程获取锁成功
- 如果有线程持有,则让当前线程进入
entryList
进行阻塞,如果Owner
持有的线程已经释放了锁,在EntryList
中的线程去竞争锁的持有权(非公平) - 如果代码块中调用了
wait()
方法,则会进去WaitSet
中进行等待
参考回答:
-
Synchronized
【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】 -
它的底层由
monitor
实现的,monitor
是jvm
级别的对象(C++
实现),线程获得锁需要使用对象(锁)关联monitor
-
在
monitor
内部有三个属性,分别是owner
、entrylist
、waitset
-
其中
owner
是关联的获得锁的线程,并且只能关联一个线程;entrylist
关联的是处于阻塞状态的线程;waitset
关联的是处于Waiting状态的线程
2. synchronized
关键字的底层原理-进阶
Monitor
实现的锁属于重量级锁,你了解过锁升级吗?
-
Monitor
实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 -
在
JDK 1.6
引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
2.1. 对象的内存结构
在HotSpot
虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header
)、实例数据(Instance Data
)和对齐填充
我们需要重点分析MarkWord
对象头
2.2. MarkWord
hashcode
:25位的对象标识Hash
码
age
:对象分代年龄占4位
biased_lock
:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁
thread
:持有偏向锁的线程ID
,占23位
epoch
:偏向时间戳,占2位
ptr_to_lock_record
:轻量级锁状态下,指向栈中锁记录的指针,占30位
ptr_to_heavyweight_monitor
:重量级锁状态下,指向对象监视器Monitor
的指针,占30位
我们可以通过lock
的标识,来判断是哪一种锁的等级
- 后三位是001表示无锁
- 后三位是101表示偏向锁
- 后两位是00表示轻量级锁
- 后两位是10表示重量级锁
2.3. 再说Monitor
重量级锁
每个 Java
对象都可以关联一个Monitor
对象,如果使用synchronized
给对象上锁(重量级)之后,该对象头的Mark Word
中就被设置指向Monitor
对象的指针
简单说就是:每个对象的对象头都可以设置monoitor
的指针,让对象与monitor
产生关联
2.4. 轻量级锁
在很多的情况下,在Java
程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM
引入了轻量级锁的概念。
static final Object obj = new Object();public static void method1() {synchronized (obj) {// 同步块 Amethod2();}
}public static void method2() {synchronized (obj) {// 同步块 B}
}
加锁的流程
1.在线程栈中创建一个Lock Record
,将其obj
字段指向锁对象。
2.通过CAS
指令将Lock Record
的地址存储在对象头的mark word
中(数据进行交换),如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record
第一部分为null
,起到了一个重入计数器的作用。
4.如果CAS
修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁过程
1.遍历线程栈,找到所有obj
字段等于当前锁对象的Lock Record
。
2.如果Lock Record
的Mark Word
为null
,代表这是一次重入,将obj
设置为null
后continue
。
3.如果Lock Record
的Mark Word
不为null
,则利用CAS
指令将对象头的mark word
恢复成为无锁状态。如果失败则膨胀为重量级锁。
2.5. 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS
操作。
Java 6
中引入了偏向锁来做进一步优化:只有第一次使用CAS
将线程ID
设置到对象的Mark Word
头,之后发现
这个线程ID
是自己的就表示没有竞争,不用重新CAS
。以后只要不发生竞争,这个对象就归该线程所有
static final Object obj = new Object();public static void m1() {synchronized (obj) {// 同步块 Am2();}
}public static void m2() {synchronized (obj) {// 同步块 Bm3();}
}public static void m3() {synchronized (obj) {}
}
加锁的流程
1.在线程栈中创建一个Lock Record
,将其obj
字段指向锁对象。
2.通过CAS
指令将Lock Record
的**线程id
**存储在对象头的mark word
中,同时也设置偏向锁的标识为101,如果对象处于无锁状态则修改成功,代表该线程获得了偏向锁。
3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record
第一部分为null
,起到了一个重入计数器的作用。与轻量级锁不同的时,这里不会再次进行cas
操作,只是判断对象头中的线程id
是否是自己,因为缺少了cas
操作,性能相对轻量级锁更好一些
解锁流程参考轻量级锁
2.6. 参考回答
Java
中的synchronized
有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
描述 | |
---|---|
重量级锁 | 底层使用的Monitor 实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS 操作,保证原子性 |
偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS 操作,之后该线程再获取锁,只需要判断mark word 中是否是自己的线程id即可,而不是开销相对较大的CAS 命令 |
一旦锁发生了竞争,都会升级为重量级锁
3. 你谈谈JMM
(Java
内存模型)
JMM
(Java Memory Model
) Java
内存模型,是java
虚拟机规范中所定义的一种内存模型。
Java
内存模型(Java Memory Model
)描述了Java
程序中各种变量(线程共享变量)的访问规则,以及在JVM
中将变量存储到内存和从内存中读取变量这样的底层细节。
特点:
-
所有的共享变量都存储于主内存(计算机的
RAM
)这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。 -
每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
-
线程对变量的所有的操作(读,写)都必须在工作内存中完成,而不能直接读写主内存中的变量,不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存完成。
4. CAS
你知道吗?
4.1. 概述及基本工作流程
CAS
的全称是: Compare And Swap
(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC
(java.util.concurrent
)包下实现的很多类都用到了CAS
操作
-
AbstractQueuedSynchronizer
(AQS
框架) -
AtomicXXX
类
例子:
我们还是基于刚才介绍过的JMM
内存模型进行说明
- 线程1与线程2都从主内存中获取变量
int a = 100
,同时放到各个线程的工作内存中
一个当前内存值
V
、旧的预期值A
、即将更新的值B
,当且仅当旧的预期值A
和内存值V
相同时,将内存值修改为B
并返回true
,否则什么都不做,并返回false
。如果CAS
操作失败,通过自旋的方式等待并再次尝试,直到成功
- 线程1操作:
V
:int a = 100
,A
:int a = 100
,B
:修改后的值:int a = 101 (a++)
- 线程1拿
A
的值与主内存V
的值进行比较,判断是否相等 - 如果相等,则把
B
的值101更新到主内存中
- 线程1拿
- 线程2操作:
V
:int a = 100
,A
:int a = 100
,B
:修改后的值:int a = 99(a--)
- 线程2拿
A
的值与主内存V
的值进行比较,判断是否相等(目前不相等,因为线程1已更新V
的值99) - 不相等,则线程2更新失败
- 线程2拿
-
自旋锁操作
-
因为没有加锁,所以线程不会陷入阻塞,效率较高
-
如果竞争激烈,重试频繁发生,效率会受影响
-
需要不断尝试获取共享内存V中最新的值,然后再在新的值的基础上进行更新操作,如果失败就继续尝试获取新的值,直到更新成功
4.2. CAS
底层实现
CAS
底层依赖于一个Unsafe
类来直接调用操作系统底层的CAS
指令
都是native
修饰的方法,由系统提供的接口执行,并非java
代码实现,一般的思路也都是自旋锁实现
在java
中比较常见使用有很多,比如ReentrantLock
和Atomic
开头的线程安全类,都调用了Unsafe
中的方法
ReentrantLock
中的一段CAS
代码
4.3. 乐观锁和悲观锁
-
CAS
是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。 -
synchronized
是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
5. 请谈谈你对volatile
的理解?
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile
修饰之后,那么就具备了两层语义:
5.1. 保证线程间的可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile
关键字会强制将修改的值立即写入主存。
一个典型的例子:永不停止的循环
package com.dcxuexi.basic;// 可见性例子
// -Xint
public class ForeverLoop {static boolean stop = false;public static void main(String[] args) {new Thread(() -> {try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}stop = true;System.out.println("modify stop to true...");}).start();foo();}static void foo() {int i = 0;while (!stop) {i++;}System.out.println("stopped... c:"+ i);}
}
当执行上述代码的时候,发现foo()
方法中的循环是结束不了的,也就说读取不到共享变量的值结束循环。
主要是因为在JVM
虚拟机中有一个JIT
(即时编辑器)给代码做了优化。
上述代码
while (!stop) { i++; }
在很短的时间内,这个代码执行的次数太多了,当达到了一个阈值,
JIT
就会优化此代码,如下:while (true) { i++; }
当把代码优化成这样子以后,及时
stop
变量改变为了false
也依然停止不了循环
解决方案:
第一:
在程序运行的时候加入vm
参数-Xint
表示禁用即时编辑器,不推荐,得不偿失(其他程序还要使用)
第二:
在修饰stop
变量的时候加上volatile
,表示当前代码禁用了即时编辑器,问题就可以解决,代码如下:
static volatile boolean stop = false;
5.2. 禁止进行指令重排序
用volatile
修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
在去获取上面的结果的时候,有可能会出现4种情况
情况一:先执行actor2
获取结果--->0
,0(正常)
情况二:先执行actor1
中的第一行代码,然后执行actor2
获取结果--->0
,1(正常)
情况三:先执行actor1
中所有代码,然后执行actor2
获取结果--->1
,1(正常)
情况四:先执行actor1
中第二行代码,然后执行actor2
获取结果--->1
,0(发生了指令重排序,影响结果)
解决方案
在变量上添加volatile
,禁止指令重排序,则可以解决问题
屏障添加的示意图
- 写操作加的屏障是阻止上方其它写操作越过屏障排到
volatile
变量写之下 - 读操作加的屏障是阻止下方其它读操作越过屏障排到
volatile
变量读之上
其他补充
我们上面的解决方案是把volatile
加在了int y
这个变量上,我们能不能把它加在int x
这个变量上呢?
下面代码使用volatile
修饰了x
变量
屏障添加的示意图
这样显然是不行的,主要是因为下面两个原则:
- 写操作加的屏障是阻止上方其它写操作越过屏障排到
volatile
变量写之下 - 读操作加的屏障是阻止下方其它读操作越过屏障排到
volatile
变量读之上