一、引言
这篇文章码了小编***个小时,点个赞不过分吧~~
文本内容有点多,如果有写错或者不好地方,还请多多指教~~~~~~~
Table of Contents
二、倔强青铜
2.1 多线程一定快吗?
我们先来看下面一段代码,有两个方法对各自a、b属性进行累加操作,其中concurrency方法是采用多线程进行操作,结果如下:
-
/**
-
* @Auther: IT贱男
-
* @Date: 2020/3/9 10:37
-
* @Description:
-
*/
-
public
class ConcurrencyTest {
-
-
// 累加次数
-
private
static
final
long count =
10000L;
-
-
public static void main(String[] args) throws InterruptedException {
-
concurrency();
-
serial();
-
}
-
-
/**
-
* 多线程累加
-
*
-
* @throws InterruptedException
-
*/
-
private static void concurrency() throws InterruptedException {
-
long start = System.currentTimeMillis();
-
-
// 启动新线程执行运行操作
-
Thread thread =
new Thread(
new Runnable() {
-
@Override
-
public void run() {
-
int a =
0;
-
for (
int i =
0; i < count; i++) {
-
a +=
5;
-
}
-
}
-
});
-
thread.start();
-
-
int b =
0;
-
for (
int i =
0; i < count; i++) {
-
b--;
-
}
-
// 等线程执行完
-
thread.join();
-
long end = System.currentTimeMillis() - start;
-
System.out.println(
"concurrency 总共耗时" + end);
-
}
-
-
/**
-
* 单线程累加
-
*/
-
private static void serial() {
-
long start = System.currentTimeMillis();
-
int a =
0;
-
for (
int i =
0; i < count; i++) {
-
a +=
5;
-
}
-
int b =
0;
-
for (
int i =
0; i < count; i++) {
-
b--;
-
}
-
long end = System.currentTimeMillis() - start;
-
System.out.println(
"serial 总共耗时" + end);
-
}
-
}
那这边的答案是"不一定"的,小编测试了几组数据如下(抽取部分结果):
循环次数 | 单线程执行 | 多线程执行 | 效率 |
---|---|---|---|
1万 | 0 | 1 | 慢 |
1万 | 0 | 0 | 相等 |
十万 | 2 | 2 | 相等 |
十万 | 1 | 1 | 相等 |
由以上的结果可以明确我们的答案是正确的,那为什么多线程在某些情况下会比单线程还要慢呢? 这是因为多线程有创建和上下文切换的开销。
2.2 上下文切换
那什么是上下文切换呢?
目前来说即使是单核处理器也支持多线程执行代码,CPU通过给个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片一般是几十毫秒,所以CPU需要通过不停地切换线程来执行。 假设当我们线程A获得CPU分配的时间片等于10毫秒,执行10毫秒之后,CPU需要切换到线程B去执行程序。等线程B的时间片执行完事了,又切回线程A继续执行。
显然易见,我们CPU相当于是循环的切换上下文,来达到同时执行的效果。当前执行完一个时间片后会切换下一个任务。但是在切换前会保存当前任务的状态,方便下次切换会这个任务的时候,可以恢复这个任务之前的状态。 所以任务从保存到再次被加载的过程就是一次上下文切换。
2.3 测试上下文切换次数
这里我们需要使用一个命令叫做:"vmstat 1",这个命令是linux系统上的,可对操作系统的进程、虚拟内存、CPU活动进行监控。看下图CS(Content Switch) 表示上下文切换的次数,从图可见系统一般CS的值维持在600~800之间,当我们一直在运行ConcurrencyTest程序时,很明细发现CS飙升到1000以上。
2.4 Java内存模型
在我们学习sync原理之前,我们需要搞清楚Java内存模型的一个概念知识。很重要、很重要、很重要
Java内存模型全称:Java Memory Model ,简称Java内存模型或者JMM,Java线程之间的通信由JMM来控制,JMM决定一个线程对共享变量的写入,何时对另外一个线程可见。我们由图可见,线程之间的共享变量是存储在主内存当中,每一个线程都有一个属于自己的本地内存(也可以叫做工作内存),这个本地内存中存储了主内存当中的共享变量。就相当于把主内存的共享变量copy了一份给自己。为了提供效率,线程是不会直接与主内存进行打交道,而是通过本地内存来进行数据的读取。
如果线程A与线程B之间要通信,需要经历下面两个步骤:
1 )线程A把本地内存A中更新过的共享变量,刷新到主内存当中去。
2 )线程B到主内存中重新读取更新后的共享变量。
2.5 主内存与工作内存之间的数据交互过程
那么主内存与工作内存之间的交互经过了哪些步骤呢?
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放之后的变量才可以被其他线程锁定。
read(读取):作用于主内存的变量,读取主内存变量的值。
load(载入):作用于主内存的变量,把read操作从主内存中得到的变量值放入到线程本地内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量传递给执行引擎。
assign(赋值):作用域工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。
store(存储):作用域工作内存的变量,把工作内存中的一个变量值传输到主内存中,以便随后的write操作。
write(写入):作用域工作内存的变量,把stroe操作从工作内存中一个变量的值传送到主内存的变量中去。
JMM是一种规范,其中定义几条规则,小编挑选出相对本文比较重要的:
1、如果想要把一个变量从主内存复制到工作内存,就需要按照顺序执行read和load操作,如果把变量从工作内存同步到主内存中,就要按照顺序执行store和write操作。但Java内存模型只要求上述操作必须按照顺序执行,而没有保证必须是连续执行。
2、程序中如果有同步操作才会有lock和unlock操作,一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,执行多次后,必须执行相对应次数但unlock操作,变量才会被解锁。lock和unlock必须成对出现。
3、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或者assign操作初始化变量但值。
4、java内存模型同步规则小编暂时提到这么多,感兴趣的小伙伴可以自行去了解一下
三、秩序白银
3.1 多线程带来的可见性问题
什么是可见性问题呢?
所谓可见性:一个线程对主内存的修改可以及时被其他线程观察到。
当一个共享属性,被线程二修改了,但是线程一无法获得最新的值,导致死循环。原因Java内存模型也说清楚了,线程是和本地内存做交互的。
1、线程一把falg属性读取到线程私有的本地内存中,值为true。
2、线程二把falg属性修改为false,并且刷新到主内存当中,但是线程一它是不知道falg被修改了。
-
public
class SyncExample5 {
-
-
static
boolean falg =
true;
-
-
// 锁对象
-
static Object lock =
new Object();
-
-
public static void main(String[] args) throws InterruptedException {
-
-
// 线程一
-
new Thread(
new Runnable() {
-
@Override
-
public void run() {
-
while (falg) {
-
// 默认不可见,死循环,放开以下注释即可解决不可见操作
-
-
// 方式一,加上sycn操作即可解决可见性问题
-
// synchronized (lock){}
-
-
// 方式二, println 方法实现加上了同步机制,保证每次输出都是最新值
-
// System.out.println(falg);
-
}
-
}
-
}).start();
-
-
// 睡眠两秒
-
Thread.sleep(
2000L);
-
-
// 线程二
-
new Thread(
new Runnable() {
-
@Override
-
public void run() {
-
falg =
false;
-
System.out.println(
"falg 值已修改");
-
}
-
}).start();
-
}
-
}
sync怎么解决可见性问题呢?
这个就涉及到本地内存与工作内存交互的步骤了,还记得文本上面有讲的8个步骤吗?
如果程序中有加同步的机制,则会有Lock、Unlock操作,Lock操作会使本地内存中的属性失效,从而去主内存中重新读取数据。
3.2 多线程带来的原子性问题
什么是原子性问题呢?
所谓原子性:提供了互斥访问,同一个时刻只能有一个线程来对它进行操作。
这里一次任务累加1千次,同时启动5个线程进行累加,最后的结果正常应该是5000才对,但由于多线程会造成不一样的结果。
-
public
class SyncExample6 {
-
-
static
int index =
0;
-
-
static Object lock =
new Object();
-
-
public static void main(String[] args) throws InterruptedException {
-
-
// index 累加 1000次,使用lambda表达式
-
Runnable task = () -> {
-
// 不加sync则不能保证原子操作
-
// synchronized (lock) {
-
for (
int i =
0; i <
1000; i++) {
-
index++;
-
}
-
// }
-
};
-
-
// 启动五个线程来执行任务
-
for (
int i =
0; i <
5; i++) {
-
Thread thread =
new Thread(task);
-
thread.start();
-
}
-
-
// 为了代码直观直接睡眠等待结果,实际需要调用线程的join方法等待线程结束
-
Thread.sleep(
2000L);
-
System.out.println(
"index = " + index);
-
}
-
}
我们使用java命令来编译以上代码:
javac SyncExample6.java
javap -p -v SyncExample6.class ,这样我们就能看到sync到底在底层做了什么事。
编译代码之后找到“lambda$main$0”,因为我们同步机制是写在main方法中,用lambda表达式所写。
-
private
static
void lambda$main$
0();
-
descriptor: ()V
-
flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
-
Code:
-
stack=
2, locals=
3, args_size=
0
-
0: iconst_0
-
1: istore_0
-
2: iload_0
-
3: sipush
1000
-
6: if_icmpge
39
-
9: getstatic #
18
// Field lock:Ljava/lang/Object;
-
12: dup
-
13: astore_1
-
14: monitorenter
-
15: getstatic #
14
// Field index:I
-
18: iconst_1
-
19: iadd
-
20: putstatic #
14
// Field index:I
-
23: aload_1
-
24: monitorexit
-
25:
goto
33
-
28: astore_2
造成原子性的问题的原因是什么?
这个就涉及到文章一开始所讲的上下文切换的知识点,index ++ 一共涉及到4条指令,如下
-
15: getstatic
#14
// 步骤一:获取index值
-
18: iconst_1
// 步骤二:准备常量1
-
19: iadd
// 步骤三:相加操作
-
20: putstatic
#14
// 步骤四:重新赋值
以上这4条指令就是index ++ 的四个步骤,假设我们线程一进来,执行到步骤三,这个时候CPU切换线程。切换到线程二,线程二执行步骤一,这个时候index的值还是等于0,因为线程一并没有执行步骤四就被切换上下文了。 等线程二执行完成,又切回到线程一,线程一会接着执行步骤三,并不会重新获取index的值,这就导致计算结果不正确了。
sync怎么解决原子性问题呢?
-
14: monitorenter
-
15: getstatic
#14
// Field index:I
-
18: iconst_1
-
19: iadd
-
20: putstatic
#14
// Field index:I
-
23: aload_1
-
24: monitorexit
当我们加上了sync同步机制之后, 会插入monitorenter、monitorexit两条指令。
又到了假设环节:假设线程一执行到步骤三,被切换到线程二,当我们线程二执行monitorenter这个指令会发现,这个对象已经被其他线程占用了,所以就只能等待着不会进行操作。现在又切回到线程一,线程一操作完整个步骤执行monitorexit来释放锁。这个时候线程二才可以获得锁。 这样一操作就能保证同一个时刻只能有一个线程来对它进行操作,从而保证原子性。
monitorenter指令是在编译后插入到同步代码块到开始位置,而monitorexit是插入到同步代码块结束位置和异常位置。JVM需要保障每个monitorenter必须有对应的monitorexit。任何一个对象都会有一个monitor来关联,当且一个monitor被持有后,它就处理锁定状态。当线程执行到monitorenter指令的时候,将会尝试获取对象所对应的monitor的所有权,即尝试获取锁对象。
3.3 多线程带来的有序性问题
什么是有序性问题呢?
有序性,指的是程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。
-
// 指定使用并发测试
-
@JCStressTest
-
// 预测的结果与类型,附加描述信息,如果1,4 则是ok,如果结果有为0也能勉强接受
-
@Outcome(id = {
"1",
"4"}, expect = Expect.ACCEPTABLE, desc =
"ok")
-
@Outcome(id = {
"0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc =
"denger")
-
// 标注需要测试的类
-
@State
-
public
class TestJMM {
-
-
int num =
0;
-
boolean ready =
false;
-
-
@Actor
-
public void actor1(I_Result r) {
-
if (ready) {
-
r.r1 = num + num;
-
}
else {
-
r.r1 =
1;
-
}
-
}
-
-
@Actor
-
public void actor2(I_Result r) {
-
num =
2;
-
ready =
true;
-
}
-
}
是时候贴一段代码凑文章字数了,这里代码用了Jcstress高并发测试框架,目的是为了能够演示有序性所导致到问题。
小伙伴可以先仔细看以上代码,假设actor1、actor2 各有一个线程进来,想想 r.r1 的值会产生几种情况。
小编告诉你吧,其实答案有三种,分别是:1、4、0
出现1的情况:
1)假设 actor1先获得执行权,ready = false ,则 r.r1 = 1;
2)假设 actor2先获得执行权,执行到num = 2, 线程切换到actor1,ready还是为false,r.r1 = 1;
出现4的情况:
1)假设actor2先获得执行权,执行完,此时ready = true,num = 2 ,等到在执行actor1时,结果为4;
出现0的情况:
1)这里就是重点了,假设actor2获得执行权,由于指令重排序导致actor2代码顺序更换。
这个时候执行到ready = true,线程切换到actor1,这个时候ready已经等于true了,但是num还是0,所以就出现了0的情况。
-
@Actor
-
public void actor2(I_Result r) {
-
// 由于指令重排序,导致下面代码更换了顺序,如下:
-
ready =
true;
-
num =
2;
-
}
我们用压测来执行以下代码吧,使用maven 执行 clean install,会生成一个jar包,直接用命令启动jar包就行了,Jcstress使用方式小编就不多说了,感兴趣的小伙伴可以自行学习下, 执行的结果也符合我们预期的值。
sync怎么解决有序性问题呢?
这个时候只需要在actor1和actor2分别加上锁操作,由于它们的锁对象都是同一个,哪怕由于指令重排序执行到actor2的ready = true,这个时候线程切换到actor1,但是有加锁所以actor1也只能等着。 等到actor2 把 num = 2 执行完,actor1 才可以拿到锁对象。
-
// 指定使用并发测试
-
@JCStressTest
-
// 预测的结果与类型,附加描述信息
-
@Outcome(id = {
"1"}, expect = Expect.ACCEPTABLE, desc =
"ok")
-
// 因为sync解决有序性问题,不会有0的出现,为了方便观察结果,我们把4设置成能勉强接受的值
-
@Outcome(id = {
"4"}, expect = Expect.ACCEPTABLE_INTERESTING, desc =
"denger")
-
@Outcome(id = {
"0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc =
"denger")
-
// 标注需要测试的类
-
@State
-
public
class TestJMM {
-
-
int num =
0;
-
boolean ready =
false;
-
-
Object lock =
new Object();
-
-
@Actor
-
public void actor1(I_Result r) {
-
synchronized (lock) {
-
if (ready) {
-
r.r1 = num + num;
-
}
else {
-
r.r1 =
1;
-
}
-
}
-
}
-
-
@Actor
-
public void actor2(I_Result r) {
-
synchronized (lock) {
-
num =
2;
-
ready =
true;
-
}
-
}
-
}
测试结果如下:
四、荣耀黄金
4.1 sync可重入特性
什么是可重入呢?
即一个线程可以多次执行synchronzied重复获取同一把锁。 sync底层锁对象中包含了一个计数器(recursions 变量),会记录线程获得了几次锁。 当我们同一个线程获得了锁,计数器则会+1,执行完同步代码块,计数器-1。 直到计数器的数量为0,就释放这个锁对象。
-
public
class SyncExample8 {
-
-
public static void main(String[] args) {
-
new MyThread().start();
-
}
-
-
}
-
-
class MyThread extends Thread {
-
@Override
-
public void run() {
-
synchronized (MyThread.class) {
-
System.out.println(getName() +
"进入了同步代码块1");
-
synchronized (MyThread.class) {
-
System.out.println(getName() +
"进入了同步代码块2");
-
}
-
}
-
}
-
}
运行结果如下,我们可以很明细的看出在输出“同步代码块1”之后,不需要等待锁释放,即可进入第二个同步代码块。这样的一个特性可以避免死锁的发生,也可以更好的封装代码(即:同步代码块中的代码,可以分成多个方法来写)。
输入结果如下:
Thread-0进入了同步代码块1
Thread-0进入了同步代码块2
4.2 sync不可中断特性
不可中断只指,线程二在等待线程一释放锁的时候,是不可被中断的。
当一个线程获得锁之后,另外一个线程一直处于堵塞或者等待状态,前一个线程不释放锁,后一个线程会一直被阻塞或等待,所以sync是不可中断锁。
-
public
class SyncExample9 {
-
-
private
static Object lock =
new Object();
-
-
public static void main(String[] args) throws InterruptedException {
-
Runnable run = () -> {
-
synchronized (lock) {
-
String name = Thread.currentThread().getName();
-
System.out.println(name +
"进入同步代码块");
-
try {
-
// 让线程一持有锁
-
Thread.sleep(
888888L);
-
}
catch (InterruptedException e) {
-
e.printStackTrace();
-
}
-
}
-
};
-
-
// 创建线程一先执行同步代码快
-
Thread t1 =
new Thread(run);
-
t1.start();
-
-
// 主线程睡眠一下,保证上面线程先执行
-
Thread.sleep(
1000L);
-
-
// 后开启线程取执
-
Thread t2 =
new Thread(run);
-
t2.start();
-
-
System.out.println(
"开始中断线程二");
-
// 强行线程二中断
-
t2.interrupt();
-
System.out.println(
"线程一状态" + t1.getState());
-
System.out.println(
"线程二状态" + t2.getState());
-
-
}
-
-
}
当我们线程一进入同步代码之后,一直持有锁,并且睡眠了(也证实了sleep方法睡眠不会释放锁对象)。
此时线程二启动去尝试获取锁,获取失败之后就变成堵塞状态,哪怕我们强行中断线程二,最后看到线程二的状态仍是堵塞的。
Thread-0进入同步代码块
开始中断线程二
线程一状态TIMED_WAITING
线程二状态BLOCKED
4.3 反汇编学习sync原理
使用javap反汇编java代码,引入monitor概念。
-
public
class SyncExample10 {
-
-
private
static Object lock =
new Object();
-
-
public static void main(String[] args) throws InterruptedException {
-
synchronized (lock) {
-
System.out.println(
"1");
-
}
-
}
-
-
public synchronized void test() {
-
System.out.println(
"1");
-
}
-
-
}
我们使用javac、javap 两个命令对SyncExample10来进行编译
javac SyncExample10.java
javap -v -p SyncExample10.class
编译后的指令就如下啦,我们主要看main方法里面的内容,着重看 monitorenter、monitorexit 两个指令
-
public static void main(java.lang.String[]) throws java.lang.InterruptedException;
-
descriptor: ([Ljava/lang/String;)V
-
flags: ACC_PUBLIC, ACC_STATIC
-
Code:
-
stack=2, locals=3, args_size=1
-
0: getstatic
#2
-
3: dup
-
4: astore_1
-
5: monitorenter // 这里
-
6: getstatic
#3
-
9: ldc
#4
-
11: invokevirtual
#5
-
14: aload_1
-
15: monitorexit // 这里
-
16: goto 24
-
19: astore_2
-
20: aload_1
-
21: monitorexit // 这里
-
22: aload_2
-
23: athrow
-
24:
return
monitorenter 指令
当我们进入同步代码块的时候会先执行monitorenter指令,每一个对象都会和一个monitor监视器关联,监视器被占用时会被锁住,其他线程无法来获取该monitor。当其他线程执行monitorente指令时,它会尝试去获取当前对象对应的monitor的所有权。
monitor里面有两个很重要成员变量:
owner: 当一个线程获取到该对象的锁,就把线程当前赋值给owner。
recursions:会记录线程拥有锁的次数,重复获取锁当前变量也会+1,当一个线程拥有monitor后,其他线程只能等待。
monitorenter执行流程如下:
1)若monitor的进入次数为0时,线程可以进入monitor,并将monitor进入的次数(recursions)+1,当前线程成为montiro的owner(所有者);
2)若线程已拥有monitor的所有权,允许它重入monitor,进入一次次数+1 (可重复特性);
3)若其他线程已经占有monitor,那么当前尝试获取monitor的线程会被阻塞,一直到monitor进入次数为变0,才能重新被再次获取。
monitorexit 指令
既然我们同步代码块进入时计数器会执行+1操作,那么我们退出的时候,计数器当然要执行-1;
要注意,能够执行monitorexit指令的线程,一定是拥有当前对象的monitor所有权的线程。 当我们执行monitorexit指令计数器减到为0时,当前线程就不再拥有monitor所有权。其他被阻塞的线程即可再一次去尝试获取这个monitor的所有权。
大家仔细看看上面编译出来的指令,其实monitoreexit是有两个的,为什么呢?
因为需要保证如果同步代码块执行抛出了异常,则也需要释放锁对象。等到下次面试官问你,synchronized如果抛异常了,会不会释放锁对象,答案是:会的。
ACC_SYNCHRONIZED 修饰
刚刚我们所看到的是mian方法中同步代码块所编译后的指令,以下是同步方法编译后指令
可以看到同步方法在反汇编后,会增加ACC_SYNCHRONIZED修饰,会隐式调用monitorenter、mointorexit,在执行同步方法前会调用monitorenter,在方法结束之后会调用monitorexit。
-
public synchronized void
test();
-
descriptor: ()V
-
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
-
Code:
-
stack=2, locals=1, args_size=1
-
0: getstatic
#3 // Field java/lang/System.out:Ljava/io/PrintStream;
-
3: ldc
#4 // String 1
-
5: invokevirtual
#5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
-
8:
return
-
LineNumberTable:
-
line 19: 0
-
line 20: 8
五、尊贵铂金
5.1 montior 监视器锁
刚刚上文有提到每一个对象都会和一个monitor监视器关联,真正的锁都是靠monitor监视器来完成,
那monitor到底是个啥玩意呢? 小编偷偷告诉你,其实monitor是用C++所写。
http://hg.openjdk.java.net/jdk8/jdk8/hotspot/ 网址都给你们找好了,点击左边zip、gz下载都行。 网速不好的同学可以在网上“hotspot 源码下载” ,下载之后文件如下图:
下载之后为了方便浏览,小编建议你们可以去下载一个CLion工具来看代码,或者直接用文本编辑器打开也行。
java对象怎么和monitor关联的呢?
这里就牵扯到另外一个知识点,我们每一个对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。而这个对象头就包含了一个monitor的引用地址,指向了一个具体的monitor对象。
monitor里面包含了什么?
我们先找到monitor对象对应的源文件:/src/share/vm/runtime/objectMonitor.hpp,往下翻可以看到ObjectMonitor的构造方法,里面有一系列成员属性。
-
ObjectMonitor() {
-
_header =
NULL;
-
_count =
0;
-
_waiters =
0,
-
_recursions =
0;
// 记录线程的重入次数
-
_object =
NULL;
-
_owner =
NULL;
// 标识拥有该monitor的线程
-
_WaitSet =
NULL;
// 存储正处于wait状态的线程
-
_WaitSetLock =
0 ;
-
_Responsible =
NULL ;
-
_succ =
NULL ;
-
_cxq =
NULL ;
// 存放竞争失败线程的单向链表
-
FreeNext =
NULL ;
-
_EntryList =
NULL ;
// 存储等待锁block状态的线程
-
_SpinFreq =
0 ;
-
_SpinClock =
0 ;
-
OwnerIsThread =
0 ;
-
_previous_owner_tid =
0;
-
}
挑几个比较重要的来说一下:
_recursions:这个在上文讲monitorenter指令的时候有提到,就是记录线程线程获取锁的次数,获取到锁该属性则会+1,退出同步代码块则-1;
_owner:当一个线程获得了monitor的所有权,则该对象会保存到_owner中。
_WaitSet:当线程入wait状态,则会存储到_WaitSet当中。
_cxq :当线程之间开始竞争锁,如果锁竞争失败后,则会加入_cxq链表中。
_EntryList:当新线程进来尝试去获取锁对象,又没有获取到对象的时候,则会存储到_EntryList当中。
5.2 monitor 竞争
什么情况下会竞争?
当多个线程执行同步代码块的时候,这个时候就会出现锁竞争。
当线程执行同步代码块时,先执行monitorenter指令, 这个时候会调用interpreterRuntime.cpp中的函数
源文件如下:src/share/vm/interpreter/interpreterRuntime.cpp,搜索:monitorenter
-
IRT_ENTRY_NO_ASYNC(
void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
-
// 代码省略
-
-
// 是否用偏向锁
-
if (UseBiasedLocking) {
-
// Retry fast entry if bias is revoked to avoid unnecessary inflation
-
ObjectSynchronizer::fast_enter(h_obj, elem->lock(),
true, CHECK);
-
}
else {
-
// 重量级锁
-
ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
-
}
-
-
// 代码省略
-
IRT_END
线程之间如何竞争锁的?
对于重量级锁,monitorenter函数中会调用 :ObjectSynchronizer::slow_enter,
最终调用到这个函数上:ObjectMonitor::enter,源码位于:/src/share/vm/runtime/objectMonitor.cpp
-
void ATTR ObjectMonitor::enter(TRAPS) {
-
// The following code is ordered to check the most common cases first
-
// and to reduce RTS->RTO cache line upgrades on SPARC and IA32 processors.
-
Thread *
const Self = THREAD ;
-
void * cur ;
-
-
// 1、通过CAS操作尝试把monitor的_owner设置成当前线程
-
cur = Atomic::cmpxchg_ptr (Self, &_owner,
NULL) ;
-
if (cur ==
NULL) {
-
assert (_recursions ==
0 ,
"invariant") ;
-
assert (_owner == Self,
"invariant") ;
-
return ;
-
}
-
-
// 2、重入锁
-
if (cur == Self) {
-
// 重入锁计数器也需要+1
-
_recursions ++ ;
-
return ;
-
}
-
-
// 3、如果是当前线程第一次进入该monitor
-
if (Self->is_lock_owned ((address)cur)) {
-
assert (_recursions ==
0,
"internal state error");
-
// 计数器+1
-
_recursions =
1 ;
-
// 把当前线程设置赋值给_owner
-
_owner = Self ;
-
OwnerIsThread =
1 ;
-
return ;
-
}
-
-
// TODO-FIXME: change the following for(;;) loop to straight-line code.
-
for (;;) {
-
jt->set_suspend_equivalent();
-
-
// 4、获取锁失败,则等待锁释放
-
EnterI (THREAD) ;
-
-
if (!ExitSuspendEquivalent(jt))
break ;
-
-
-
_recursions =
0 ;
-
_succ =
NULL ;
-
exit (
false, Self) ;
-
-
jt->java_suspend_self();
-
}
-
}
此处省略了锁的自旋优化等操作,文章后面会讲到
以上代码具体的操作流程如下:
1)通过CAS尝试把monitor的_owner属性设置为当前线程
2)如果之前设置的owner等于当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ; 记录重入次数。
3)如果当前线程是第一次进入monitor,设置_recursions = 1,_owner = 当前线程,该线程成功获得锁并返回。
4、如果获取锁失败,等待锁释放
5.3. monitor 等待
上文有提到,如果锁竞争失败后,会调用EnterI (THREAD) 函数,还是在objectMonitor.cpp源码中搜索:::EnterI
以下代码小编省略了部分:
-
void ATTR ObjectMonitor::EnterI (TRAPS) {
-
Thread * Self = THREAD ;
-
assert (Self->is_Java_thread(),
"invariant") ;
-
assert (((JavaThread *) Self)->thread_state() == _thread_blocked ,
"invariant") ;
-
-
// 尝试获取锁
-
if (TryLock (Self) >
0) {
-
assert (_succ != Self ,
"invariant") ;
-
assert (_owner == Self ,
"invariant") ;
-
assert (_Responsible != Self ,
"invariant") ;
-
return ;
-
}
-
-
// 自旋操作尝试获取锁
-
if (TrySpin (Self) >
0) {
-
assert (_owner == Self ,
"invariant") ;
-
assert (_succ != Self ,
"invariant") ;
-
assert (_Responsible != Self ,
"invariant") ;
-
return ;
-
}
-
-
// 当前线程封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ
-
ObjectWaiter node(Self) ;
-
Self->_ParkEvent->reset() ;
-
node._prev = (ObjectWaiter *)
0xBAD ;
-
node.TState = ObjectWaiter::TS_CXQ ;
-
-
// 通过CAS把node节点push到_cxq队列中
-
ObjectWaiter * nxt ;
-
for (;;) {
-
node._next = nxt = _cxq ;
-
if (Atomic::cmpxchg_ptr (&node, &_cxq, nxt) == nxt)
break ;
-
-
// Interference - the CAS failed because _cxq changed. Just retry.
-
// As an optional optimization we retry the lock.
-
// 再次尝试获取锁
-
if (TryLock (Self) >
0) {
-
assert (_succ != Self ,
"invariant") ;
-
assert (_owner == Self ,
"invariant") ;
-
assert (_Responsible != Self ,
"invariant") ;
-
return ;
-
}
-
}
-
-
// 挂起线程
-
for (;;) {
-
// 挂起之前再次尝试获取锁
-
if (TryLock (Self) >
0)
break ;
-
assert (_owner != Self,
"invariant") ;
-
-
if ((SyncFlags &
2) && _Responsible ==
NULL) {
-
Atomic::cmpxchg_ptr (Self, &_Responsible,
NULL) ;
-
}
-
-
// park self
-
if (_Responsible == Self || (SyncFlags &
1)) {
-
TEVENT (Inflated enter - park TIMED) ;
-
Self->_ParkEvent->park ((jlong) RecheckInterval) ;
-
// Increase the RecheckInterval, but clamp the value.
-
RecheckInterval *=
8 ;
-
if (RecheckInterval >
1000) RecheckInterval =
1000 ;
-
}
else {
-
TEVENT (Inflated enter - park UNTIMED) ;
-
// 通过park将当前线程挂起,等待锁释放
-
Self->_ParkEvent->park() ;
-
}
-
// 尝试获取锁
-
if (TryLock(Self) >
0)
break ;
-
}
-
-
return ;
-
}
以上代码具体流程概括如下:
1)进入EnterI后,先会再次尝试获取锁对象
2)把当前线程封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ ;
3)在for循环中,通过CAS把node节点push到_cxq(上文有提到这个属性)列表中,同一时刻可能有多个线程把自己到node节点push到_cxq列表中。
4)node节点push到_cxq 列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待唤醒。
5)当前线程被唤醒时,会从挂起到点继续执行,通过TryLock再次尝试锁。
5.4 monitor 释放
什么时候会释放monitor?
当线程执行完同步代码块时,调用monitorexit指令释放锁,这个时候锁就会被释放。
还是在objectMonitor.cpp源码中搜索:::exit
释放monitor过程是什么?
exit函数代码如下,当然小编也有大部分的删减,留下比较主要的代码部分。
-
void ATTR ObjectMonitor::
exit(
bool not_suspended, TRAPS) {
-
-
// 判断计数器,不等于0则执行-1
-
if (_recursions !=
0) {
-
_recursions--;
// this is simple recursive enter
-
TEVENT (Inflated
exit - recursive) ;
-
return ;
-
}
-
-
// w = 最后被唤醒的线程
-
ObjectWaiter * w =
NULL ;
-
int QMode = Knob_QMode ;
-
-
// QMode == 2,会绕过EntryList队列,从cxq队列中获取线程用于竞争锁
-
if (QMode ==
2 && _cxq !=
NULL) {
-
w = _cxq ;
-
assert (w !=
NULL,
"invariant") ;
-
assert (w->TState == ObjectWaiter::TS_CXQ,
"Invariant") ;
-
// 唤醒线程
-
ExitEpilog (Self, w) ;
-
return ;
-
}
-
-
// QMode还有还好几种策略,小编就不一一列举了
-
-
// 最后拿到了要被唤醒的线程
-
w = _EntryList ;
-
if (w !=
NULL) {
-
guarantee (w->TState == ObjectWaiter::TS_ENTER,
"invariant") ;
-
// 唤醒线程
-
ExitEpilog (Self, w) ;
-
return ;
-
}
-
-
-
}
观察以上代码,都需要调用ExitEpilog函数来唤醒线程, 还是在objectMonitor.cpp源码中搜索:::ExitEpilog
-
void ObjectMonitor::ExitEpilog (Thread * Self, ObjectWaiter * Wakee) {
-
assert (_owner == Self,
"invariant") ;
-
-
_succ = Knob_SuccEnabled ? Wakee->_thread :
NULL ;
-
ParkEvent * Trigger = Wakee->_event ;
-
-
Wakee =
NULL ;
-
-
// Drop the lock
-
OrderAccess::release_store_ptr (&_owner,
NULL) ;
-
OrderAccess::fence() ;
// ST _owner vs LD in unpark()
-
-
if (SafepointSynchronize::do_call_back()) {
-
TEVENT (unpark before SAFEPOINT) ;
-
}
-
-
DTRACE_MONITOR_PROBE(contended__exit,
this, object(), Self);
-
-
// 最重要的时候这里,调用unpark来进行唤醒
-
Trigger->unpark() ;
-
-
// Maintain stats and report events to JVMTI
-
if (ObjectMonitor::_sync_Parks !=
NULL) {
-
ObjectMonitor::_sync_Parks->inc() ;
-
}
-
}
以上代码具体流程概括如下:
1)退出同步代码块时会让_recursions - 1,当_recursions的值等于0的时候,说明线程释放了锁。
2)根据不同的策略(由QMode来指定),最终获取到需要被唤醒的线程(代码中是:w)
3)最后调用ExitEpilog函数中,最终由unpark来执行唤醒操作。
六、永恒钻石
6.1 CAS 介绍
CAS的英文单词CompareAndSwap的缩写,比较并替换。CAS需要有3个操作数:内存地址V、旧的预期值A、即将要更新的目标值B。
CAS指令执行时,当内存地址V的值与预期值A相等时,将目标值B保存到内存当中,否则就什么都不做。 整个比较并替换的操作是一个原子操作。
CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都失败,失败的线程并不会挂起,而是被告知这次竞争失败,并可以再次尝试。
优点:可以避免优先级倒置和死锁等危险,竞争比较便宜,协调发生在更细的力度级别,允许更高程度的并行机制等等。
缺点:
1、循环时间长开销很大,如果CAS失败,会一直进行尝试,如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
2、只能保证一个共享的原子操作,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
3、ABA问题,如果内存地址V初次读取的值是A,并且在准备赋值的时候检查仍然为A,那我们就能说它的值没有被其他线程改变过吗?
如果在这段期间它的值曾被改成了B,后来又被改回A,那CAS就会误认为它从来没有被改变过,这个漏洞称之为CAS操作的ABA问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类 “AtomicStampendReference”,它可以通过控制变量值的版本来保证CAS的正确性。
因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发性的正确性,如果需要解决ABA问题,改用传统的互斥同步可能比原子类更高效
介绍完CAS,那么肯定就多多少少介绍以下实现原理,我们以AtomicInteger为例,它是JDK中提供能够保障原子性操作的类。
-
/**
-
* Atomically increments by one the current value.
-
*
-
* @return the updated value
-
*/
-
public final int incrementAndGet() {
-
return unsafe.getAndAddInt(
this, valueOffset,
1) +
1;
-
}
我们点进去看它里面的方法,拿incrementAndGet方法为例子,这个方法是在原有值的基础上进行+1操作,它的实现调用Unfafe类的方法,我们再点进去看。
-
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;
-
}
Unfafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针问题,过度的使用Unsafe类会使得出错的几率变大。因此Java官方不建议使用的,Unsafe对象也不能直接调用,只能通过放射来获取。
小编这里说一下getandAddInt方法的执行流程,
var1:传进来的是this,也就是AtomicInteger实例对象;
var2:偏移量,通过结合var1就能够获得在内存中的最新值;
var4:要进行累加的值,也就是 1 ;
先通过var1+var2 获取到内存中最新的值,然后再调用compareAndSwapInt方法,这个方法又会通过var1+var2参数获取内存中最新的值,与var5的值进行比较,如果比较成功,这把var5+var4的结果更新到内存中去。如果不成功,则继续循环操作。也就是我们刚刚介绍CAS所说,比较并替换。
6.2 sync 锁升级过程
在JDK1.5以前,sync是一个重量级的锁,在1.6以后,对sync做了大量的各种优化,包含偏向锁、轻量级锁、适应性自旋、锁消除、锁粗化等等,这些技术都是为了线程之间更加高效的共享数据,以及解决竞争问题,从而达到程序的执行效率。
当然锁肯定升级的过程:无锁 —— 偏向锁 —— 轻量级锁 —— 重量级锁。
每个不同的锁都有不同的使用藏场景,在了解各种锁的特性之前,我们还需要搞清楚对象在内存中的布局!
6.3 对象的布局
我们每一个对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。
对象头:
当一个线程尝试访问sync修饰的代码块时,它先要获得锁,这个锁对象是存在对象头中的。
以Hotspot虚拟机为例,对象头里面主要包含了Mark Word(字段标记)、Klass Pointer (指针类型),如果对象是数组类型,还包含了数组的长度。
怎么又扯到Hotspot虚拟机呢? 小伙伴可以这样理解,JVM可以理解为一套规范,而Hotspot是具体的虚拟机产品。 就好比如你们要找女朋友、或者男朋友,既然找朋友是不是就要有一定的要求或者规范,JVM就可以看作这个规范,而Hotspot就是具体的男朋友或者女朋友了。
你不信? System.out.println(System.getProperties()); 运行这个代码吧,找找你们java.vm.name等于什么。
java.vm.name=Java HotSpot(TM) 64-Bit Server VM
Mark Word :里默认存储对象的HashCode、分代年龄和锁位标记。 这个也是sync锁实现的重要部分了,在运行期间,Mark Word 里存储的数据会随着锁标位置的变化而变化。 在64位虚拟机下,Mark Word是64bit大小的,其存储结构如图:
锁状态 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
cms_free | 分代年龄 | 偏向锁 | 锁位标识 | |||
无锁 | unused | HashCode | 0 | 01 | ||
偏向锁 | ThreadID(54bit)、Epoch(2bit) | 1 | 01 | |||
轻量级锁 | 指向占中锁记录的指针 | 00 | ||||
重量级 | 指向互斥量(重量级锁)的指针 | 10 |
以上这个表格数据不能乱来对不对,我们可以查看源码:src/share/vm/oops/markOop.hpp
里面注释写的很清楚了,对照以下注释反映出上面的表格,更加直观。
-
// 32 bits:
-
// --------
-
//
hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)
-
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)
-
// size:32 ------------------------------------------>| (CMS free block)
-
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
-
//
-
// 64 bits:
-
// --------
-
// unused:25
hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
-
// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
-
// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
-
// size:64 ----------------------------------------------------->| (CMS free block)
Klass Pointer :用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定是哪个对象的实例。
对象头 = Mark Word + Klass Point 在未开启指针压缩对情况下所占大小:
以64位系统为例:Mark Word = 8 bytes,指针类型 = 8 bytes ,对象头 = 16 bytes = 128bits;
实例数据:
类中定义的成员变量
对齐填充:
对齐填充并不是必然存在的,也没有什么特殊的意义,它仅仅只是占位符的作用。由于HotPort VM的自动内存管理系统要求对象起始地址必须是8字节的整倍数,当对象的实例数据部分没有对齐时,就需要通过对齐填充来不补齐。
说了这么多,都是概念性的东西,说谁不会说对不对,接下来我们尝试在把一个对象在内存中都布局输出看下:
先引入这个jar包,它能够提供我们想要看到的东西,使用方式如下:
-
<dependency>
-
<groupId>org.openjdk.jol
</groupId>
-
<artifactId>jol-core
</artifactId>
-
<version>0.10
</version>
-
</dependency>
-
public
class SyncExample4 {
-
-
static Apple apple =
new Apple();
-
-
public static void main(String[] args) {
-
// 这里使用ClassLayout来查看
-
System.out.println(ClassLayout.parseInstance(apple).toPrintable());
-
}
-
}
-
-
class Apple {
-
private
int count;
-
private
boolean isMax;
-
-
}
以下内容就是我们Java对象内存分布所查看到的内容,我们能直接看到内容有object header 翻译过来就是对象头呀, 再往下看就是loss due to the next object alignment,这个就是对齐填充,由于Apple 有一个boolean的属性,占了一个字节,所以计算机为了提高执行效率和GC垃圾回收的效率,进行了7个字节的填充(这里涉及到CPU运行小编就不多扯了)。
-
com.example.concurrency.sync.Apple
object internals:
-
OFFSET SIZE TYPE DESCRIPTION VALUE
-
0
4 (
object header)
01
00
00
00 (
00000001
00000000
00000000
00000000) (
1)
-
4
4 (
object header)
00
00
00
00 (
00000000
00000000
00000000
00000000) (
0)
-
8
4 (
object header)
43 c0
00 f8 (
01000011
11000000
00000000
11111000) (
-134168509)
-
12
4 int Apple.count
0
-
16
1
boolean Apple.isMax
false
-
17
7 (loss due
to the
next
object alignment)
-
Instance size:
24 bytes
-
Space losses:
0 bytes internal +
7 bytes external =
7 bytes total
看到这里我们确实能够确定对象头的存在,那么对象头里面不是说用31 bit存储了HashCode吗? 怎么没看见
我们再来执行一段代码, 计算一下apple的HashCode是多少,看运行结果可知,本次运行apple的HashCode是7ea987ac,我们再看看对应VALUE值也发生了改变。这里有一个概念,由于存在大小端存储方式,我们需要从后往前看。
-
public
class SyncExample4 {
-
-
static Apple apple =
new Apple();
-
-
public static void main(String[] args) {
-
// 查看HashCode
-
System.out.println(Integer.toHexString(apple.hashCode()));
-
System.out.println(ClassLayout.parseInstance(apple).toPrintable());
-
}
-
}
-
-
class Apple {
-
private
int count;
-
private
boolean isMax;
-
}
-
7ea987ac
-
# WARNING: Unable
to attach Serviceability Agent. You can try again
with escalated privileges. Two options: a) use -Djol.tryWithSudo=
true
to try
with sudo; b) echo
0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
-
com.example.concurrency.sync.Apple object internals:
-
OFFSET SIZE TYPE DESCRIPTION VALUE
-
0
4 (object header)
01 ac
87 a9 (
00000001
10101100
10000111
10101001) (
-1450726399)
-
4
4 (object header)
7e
00
00
00 (
01111110
00000000
00000000
00000000) (
126)
-
8
4 (object header)
43 c0
00 f8 (
01000011
11000000
00000000
11111000) (
-134168509)
-
12
4
int Apple.count
0
-
16
1 boolean Apple.isMax
false
-
17
7 (loss due
to the
next object alignment)
-
Instance size:
24 bytes
-
Space losses:
0 bytes internal +
7 bytes external =
7 bytes total
细心一点的小伙伴就会发现,上文不是说了对象头一共占了16个字节吗? 这里三个object header 才12个字节也不对呀?
这里JVM默认会开启指针压缩,我们可以通过参数把它关掉:
在打印看结果,就是16个字节。
-
OFFSET SIZE
TYPE DESCRIPTION VALUE
-
0
4 (
object header)
01
00
00
00 (
00000001
00000000
00000000
00000000) (
1)
-
4
4 (
object header)
00
00
00
00 (
00000000
00000000
00000000
00000000) (
0)
-
8
4 (
object header)
80
68 f5
1f (
10000000
01101000
11110101
00011111) (
536176768)
-
12
4 (
object header)
02
00
00
00 (
00000010
00000000
00000000
00000000) (
2)
最后总结以下: Java对象有三个部分组成:对象头、实例数据、对齐填充,其中对象头又包含Mark Word、Klass Pointer(如果对象是数组类型,还包含了数组的长度)。
七、至尊星耀
锁状态 | 25 bit | 31 bit | 1 bit | 4 bit | 1 bit | 2 bit |
cms_free | 分代年龄 | 偏向锁 | 锁位标识 | |||
无锁 | unused | HashCode | 0 | 01 | ||
偏向锁 | ThreadID(54bit)、Epoch(2bit) | 1 | 01 | |||
轻量级锁 | 指向占中锁记录的指针 | 00 | ||||
重量级 | 指向互斥量(重量级锁)的指针 | 10 |
7.1 偏向锁
偏向锁的原理
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。为了让线程获得的锁的代价更低,从而引入偏向锁的。
我们对照Mark Word存储结构来看,当一个线程访问同步代码快之后,会把Mark Word中的偏向锁标识由0改为1,并且存储当前线程的ID,以后该线程进入和退出同步代码的的时候,则不需要进行CAS操作来加锁和解锁。只需要简单的测试一下对象头里是否存储着指向当先线程的偏向锁,如果结果成功,表示线程已经获得了锁。如果失败,需要再查看Mark Word中的偏向锁标识是否设置成1,如果没有,则使用CAS竞争锁。
我们可以使用代码来观察下:
偏向锁在Java 6 和Java 7中默认是开启的,但是他在应用程序启动几秒钟之后才激活,我们需要先来关闭延迟启动。
-
public
class SyncExample4 {
-
-
public static void main(String[] args) {
-
Apple apple =
new Apple();
-
apple.start();
-
}
-
}
-
-
class Apple extends Thread {
-
-
private Object lock =
new Object();
-
-
@Override
-
public void run() {
-
synchronized (lock) {
-
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
-
}
-
}
-
}
-
OFFSET SIZE TYPE DESCRIPTION VALUE
-
0 4 (object header) 05 d8 86 22 (00000101 11011000 10000110 00100010) (579262469)
-
4 4 (object header) 9c 7f 00 00 (10011100 01111111 00000000 00000000) (32668)
-
8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
-
12 4 (loss due to the next object alignment)
-
Instance size: 16 bytes
-
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
由于大小端存储,原本偏向锁和锁位标识是在最后的,现在我们需要看最前8位数:00000101
第一个1 代表是偏向锁,并且锁标识01,和我们的表格也能够对应上。
偏向锁的撤销
偏向锁使用了一种等到竞争出现了才释放锁的机制,所以当其他线程来进行争夺锁的时候,持有偏向锁的线程才会释放锁。但是偏向锁的撤销的时候,需要等到一个全局安全点,也就是在这个时间点上没有正在执行的字节码。 它首先会暂停所有线程(包括拥有偏向锁的线程),然后在判断当前是不是偏向锁,如果偏向锁标识等于1,就撤销回0;
偏向锁的好处
偏向锁的好处也很显而易见,只有同一个线程来访问同步代码块的时候,效率是很高的,只需要判断当先线程和Mark Word里面存储的线程是否是一致就行了。如果程序中大多数的锁都是不同的线程来进行访问,那么这个时候偏向锁就是多余的了。
我们可以通过JVM参数来关闭偏向锁:-XX:-UseBiasedLocking
7.2 轻量级锁
什么是轻量级锁
轻量级锁是在JDK6中加入的新型锁机制,引入轻量级锁的目的是为了,在多线程交替执行同步代码块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多线程在同一时刻进入临界区,会导致轻量级锁膨胀升级为重量级锁,所以轻量级锁的出现并非代替重量级锁。
栈桢
我们在JVM虚拟中,有堆和栈,而在栈中还包含了我们对象的各种方法,一个方法就相当于一个“栈桢”。其中方法中也是可以存储内容的,其中就包含了Displaced Mark Word,这个有什么作用呢? 接着往下看
轻量级锁原理
线程在执行同步代码快之前,JVM会现在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word 复制到锁记录当中。这个就是我们刚刚所说Displaced Mark Word了。JVM利用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针。如果成功,当先线程获得锁并且将锁位标识改为00,如果失败了则需要判断当前对象的Mark Word是否指向当前线程的指针,如果是则表示当线程已经持有对象的锁,执行同步代码快。如果不是只能说明该锁对象被其他线程占用,这时的轻量级需要膨胀到重量级锁,锁位标识改为10,后面的线程进入阻塞状态。
轻量级锁的释放
解锁的时候,会使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
7.3 自旋锁
自旋锁是在JDK1.4中就已经引入了,默认是关闭的,在JDK1.6中默认几句开启了。
为什么要用自旋锁呢?自旋锁通俗易懂的来说,就是循环去获取锁。 因为在我们锁升级的过程中,如果线程竞争锁失败,就立即被挂起,然后等待被唤醒,其实这个时候性能开销是比较大的。可能线程还正在被挂起的时候,锁已经被释放掉了,所以就有了自旋锁的操作。
当线程竞争锁失败之后,先自旋来尝试获取锁,如果锁被占用的时间很短,自旋等待的效果就非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会拜拜消耗处理器资源,而不会有任何的作用。自旋默认的默认值是10次,可使用参数-XX:PreBlockSpin来更改。
适应性自选锁
由于我们自旋锁可能回带来一定的性能消耗,但是我们又不清楚设置自旋次数多少合适,所以这个时候适应性自选锁就来了。适应性自选就意味着自旋的时间不再固定了,而是由前一次在同一个锁的自旋时间及所得拥有者的状态来决定。假设在同一个同步代码块上自旋10次就能获得锁,那么虚拟机就会认为这次也能够获得锁,还允许自旋的时间稍微长一点。 那么再假设一个同步代码块从来都没有自旋成功过,那么虚拟机就可能省略自旋的过程,以免浪费性能。
光说还不如来点实际的代码,源码路径:src/share/vm/runtime/objectMonitor.cpp ,搜索::TrySpin_VaryDuration
-
int ObjectMonitor::TrySpin_VaryDuration (Thread * Self) {
-
// 固定自旋次数
-
int ctr = Knob_FixedSpin ;
-
if (ctr !=
0) {
-
while (--ctr >=
0) {
-
if (TryLock (Self) >
0)
return
1 ;
-
SpinPause () ;
-
}
-
return
0 ;
-
}
-
-
// 适应式自旋
-
for (ctr = Knob_PreSpin +
1; --ctr >=
0 ; ) {
-
if (TryLock(Self) >
0) {
-
// 成功后,修改自旋的时间
-
int x = _SpinDuration ;
-
if (x < Knob_SpinLimit) {
-
if (x < Knob_Poverty) x = Knob_Poverty ;
-
_SpinDuration = x + Knob_BonusB ;
-
}
-
return
1 ;
-
}
-
SpinPause () ;
-
}
-
}
7.4 消除锁
我们先来看以下代码:
-
public String getContent() {
-
return
new StringBuffer().append(
"a").append(
"b").append(
"c").toString();
-
}
-
@Override
-
public synchronized StringBuffer append(String str) {
-
toStringCache =
null;
-
super.append(str);
-
return
this;
-
}
StringBuffer中的append是同步的,但是我们这个getContent这个方法,每次都是新new一个对象来进行操作。所以不同的线程进来,锁住的对象也是不同的,所以就根本不会造成线程上的问题。 这个时候虚拟机即使编译器(JIT)在运行时,对一些代码上的要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,这个就是锁消除。
7.5 锁粗化
什么是锁粗化呢? JVM会探测一连串细小的操作都是用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
-
public static void main(String[] args) {
-
StringBuffer sb =
new StringBuffer();
-
for (
int i =
0; i <
100; i++) {
-
sb.append(
"a");
-
}
-
}
-
@Override
-
public synchronized StringBuffer append(String str) {
-
toStringCache =
null;
-
super.append(str);
-
return
this;
-
}
看上面代码,StringBuffer的append的方法里面是有加同步关键字的,然而我们在外面循环了100次,就要进入锁和退出锁各100次,所以这个时候JVM就会把锁粗化。 把append方法同步关键字去掉,扩大在外面来,就只需要进入和退出1次即可。
-
public static void main(String[] args) {
-
-
StringBuffer sb =
new StringBuffer();
-
synchronized (sb) {
-
for (
int i =
0; i <
100; i++) {
-
sb.append(
"a");
-
}
-
}
-
}
八、最强王者
终章:平时写代码如何对synchroized优化
终于打上王者了,不要以为打上王者就行啦,还有一些日常操作我们还需要注意到的。
减少sync的同步代码块的范围:
同步代码块精简,执行就会更快,可能轻量级锁、自旋锁就搞定了,不会升级为重量级锁。
-
public static void main(String[] args) {
-
-
StringBuffer sb =
new StringBuffer();
-
synchronized (sb) {
-
System.out.println(
"a");
-
}
-
}
降低sync锁的粒度:
锁的对象也是有讲究的,假设test01和02本身没有任何业务相关的代码,但是锁的对象越是同一个,这样岂不是并发效率就很低了。
-
public
class SyncExample4 {
-
-
public void test01(){
-
synchronized (SyncExample4.class){}
-
}
-
public void test02(){
-
synchronized (SyncExample4.class){}
-
}
-
}
读写分离:
我们尽量可以做到,读的时候不加锁,写入和删除的时候加锁,这样就可以保证多个线程同时来读取数据。
举个例子:
HashTable容器竞争激烈的并发环境下,效率低是因为多个线程竞争同一把锁,假如容器有多把锁,每一把锁用于锁住容器中一部分数据,那么多线程访问容器里面不同的数据段的数据时,线程间不会存在锁竞争,从而有效提高并发访问率。这就是ConcurrentHashMap的锁分段技术,将数据分成一段一段的存储,然后把每一段数据分配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段段数据也能被其他线程访问。
小编我终于写完了,温馨提示光看一遍印象不会特别深刻,最好能够实际动手操作以下,看下源码如何实现之类的。
转载:https://blog.csdn.net/weixin_38111957/article/details/104902100