volatile

一是保证可见性。
二是禁止指令重排序。
可见性,是让其他线程可见,一般我们是多核CPU,一旦volatile修饰的变量被一个CPU修改,修改完的值需要立即推送到主存,推送过程要经过一个总线。那么,我们说的可见性就是体现在总线这一层上。因为,我们其他的CPU一直在嗅探总线的数据流通,在缓存一致性的保障下, 能够嗅探到数据的修改,然后,如果自己的缓存行有这条数据,就置为失效。
如果下次有线程对该数据读或写,那么它会先从主存拉取数据保存到CPU自己的缓存行里边,然后再做进一步处理。
就是嗅探和MESI缓存一致性协议共同保证了CPU层的可见性。
总线如何知道volatile修饰呢?关键用了lock 前缀的指令,有两层含义。一个是将数据的修改推送到主存,一个是lock指令过控制总线的时候,其他CPU会嗅探lock指令,然后置缓存行失效。

禁止指令重排序
首先,它是禁止在编译阶段,Class结构中方法表的属性表的Code属性,存放着JVM指令,在编译期时,volatile就完成了一个内存屏障的编译。
对于volatile的写,写前加入storeStore屏障,保证写写顺序,写后加入storeLoad,保证先写后读顺序;对于volatile的读,在第一个读后加入loadLoad屏障,保证读读顺序,读后loadStore,保证先读后写顺序,StoreLoad这是个全能型屏障,开销很大,同时具有其他3个屏障的效果。执行该屏障的花销比较昂贵,因为处理器通常要把当前的写缓冲区的内容全部刷新到[内存]中(Buffer Fully Flush,一般不会单独使用。

引申:
final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。
在 初次读 final 域操作的前面插入一个 LoadLoad 屏障

特殊情况下,可以保证原子性。
如果是单条JVM指令字节码,可以保证原子性;如果是多条JVM指令字节码,不能保证原子性;(建议别深入,怕深问到指令出入栈)
如果是对 flag = true的读写,可保证,因为读和写都是1条指令字节码。
如果a++,不保证,这里涉及了3条JVM指令字节码,iload 读取a,iadd 进行a+1,istore 写a。

下面这个其他人的答案,有点含糊不理解:
read load use 作为一种读原子操作
assign store write 作为一种写原子操作
主存值read到执行引擎,执行引擎值load到一个栈存副本变量,栈存值use 到执行引擎,以便计算处理;
执行引擎值 assign到栈存副本变量,栈存副本值store到主存,主存值write 到主存的变量,以便共享;

原子操作避免 读过程 或 写过程 在中间操作被打断。
虽然use 和assign存在 被打断的可能,但工作内存和主内存,可以依旧相等。
因此在多线程内,适合flag赋值,不适合a++等非原子操作,运算结果a会对中间加数a产生依赖。

Java内存模型即Java Memory Model,简称JMM。
JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM 是整个计算机虚拟模型,所以 JMM 是隶属于 JVM 的。
一、JVM构成:
1、类加载器(ClassLoader):在 JVM 启动时或者在类运行将需要的 class 加载到JVM 中。
2、执行引擎:负责执行 class 文件中包含的字节码指令.
3、内存区(也叫运行时数据区):是在 JVM 运行的时候操作所分配的内存区。
(1)、方法区(MethodArea):用于存储类结构信息的地方,包括常量池、静态常量、构造函数等。虽然 JVM 规范把方法区描述为堆的一个辑部分, 但它却有个别名non-heap(非堆),所以大家不要搞混淆了。方法区还包含一个运行时常量池。
(2)、java 堆(Heap):存储 java 实例或者对象的地方。这块是 GC 的主要区域。从存储的内容我们可以很容易知道,方法和堆是被所有 java 线程共享的。
(3)、java 栈(Stack):java 栈总是和线程关联在一起,每当创一个线程时,JVM 就会为这个线程创建一个对应的 java 栈在这个 java 栈中,其中又会包含多个栈帧,每运行一个方法就建一个栈帧,用于存储局部变量表、操作栈、方法返回等。每一个
方法从调用直至执行完成的过程,就对应一栈帧在 java 栈中入栈到出栈的过程。所以 java 栈是线程有的。
(4)、程序计数器(PCRegister):用于保存当前线程执行的内存地址。由于 JVM 程序是多线程执行的(线程轮流切换),所以为了保证程切换回来后,还能恢复到原先状态,就需要一个独立计数器,记录之前中断的地方,可见程序计数器也是线程私有的。
(5)、本地方法栈(Native MethodStack):和 java 栈的作用差不多,只不过是为 JVM 使用到 native 方法服务的。
4、本地方法接口:主要是调用 C 或 C++实现的本地方法及回调结果。
开线程影响哪块内存?
每当有线程被创建的时候,JVM 就需要为其在内存中分配虚拟机栈和本地方法栈来记录调用方法的内容,分配程序计数器记录指令执行的位置,这样的内存消耗就是创建线程的内存代价。
Java 线程之间的通信总是隐式进行,并且采用的是共享内存模型。这里提到的共享内存模型指的就是 Java 内存模型(简称 JMM),JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。总之,JMM 就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before 原则)及其外部可使用的同步手段(synchronized/volatile 等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

比较:
synchronized 一定条件是重量级+可重用锁
作用到普通方法上,静态方法上,代码块。
普通方法是对象锁,静态方法是当前class锁,代码块看它的指定;

对单个值读写,
读多写少,用volatile,缓存行直接读;
读少写多,用sychronized作用到读写方法。
volatile锁总线,CPU嗅探,伪共享问题,总线风暴问题;
sychronized只会线程的block状态,有用户态和内核态的上下文切换,有一个线程执行的记忆,这个过程耗资源耗时间,所以提出CAS。如果写过程短,CPU执行非常快,线程blocked会非常短。

总结
重量级锁能提高程序的吞吐量。

volatile挖到操作系统的内存管理,可以看:
https://missionodd.github.io/2022/01/26/distributed-volatile-series/
Class文件结构可以看:
https://missionodd.github.io/2022/02/01/jvm-notes/


Q:synchronized 和 lock 的区别?

1,基本使用,关键字;接口
lock接口有6个方法:lock,unlock,trylock非阻塞加锁,trylock(time)非阻塞可超时加锁,lockInterruptibly可中断加锁;
Rentrantlock 实现
2,隐式加锁,显式加锁
lock在try里,unlock在finally里
3,作用方法上多种方式,lock只能作用到代码块
对于synchronized
普通方法,静态方法,方法块上有什么不同。
代码块反编译同步指令,monitorenter,monitorexit;
一定有两个出口,正常和异常。
方法上会有ACC_SYNCHRONIZED访问标志。

4,lock 支持非阻塞式加锁(信号量),支持可超时加锁,支持可中断加锁;
不阻塞,少一次上下文切换。可超时可中断,更灵活;

5,从底层原理,synchronized一定条件下 采用对象监视器monitor,lock采用AQS;
lock 新来线程A,state变量看锁对象是否持有,如果被其他人B持有,则加入到双端队列CLH尾部,需要CAS。
之后,B释放锁,通知队头线程C,非公平锁下,C又与新来的线程D进行锁竞争;公平锁下,C调度,新来的D排到CLH尾部。
Object 的monitor,新来线程A,看锁对象是否持有,如果被其他人B持有,则加入到同步队列EntrySet尾部,需要CAS。
之后,B释放锁,通知队头线程C,由于非公平锁,队头C和新来的线程D进行锁竞争。

6,synchronized在进行加锁解锁或通知等待时,只有一个同步队列Entry Set 一个等待队列Wait Set;
lock有一个同步队列Entry Set,多个等待队列Wait Set;
创建一个condition就多一个等待队列
7,锁竞争,synchronized只支持非公平锁,lock支持公平锁和非公平锁;
对于在排队的A线程,新来的线程B,AB竞争机会相同,对A不公平;如果A总是被插队,A总是在排队,A就是饥饿线程。
对于在排队的A线程,新来的线程B,B在队列末端排队,对A公平。

8,等待唤醒机制,sychronized与Object进行配合,使用notifyAll方法操作;lock需要用contdition接口配合,也是wait/notify机制,只是方法名不同await/signalAll。
如果线程获取锁,并condition的await方法,线程去哪?
在await方法,线程加入condition的等待队列,不需要CAS,直接加,此时持有当前锁,说明自己竞争成功。当调用await后,它会进行锁释放。
场景:线程A获取锁后,调用wait,A被唤醒,继续执行释放了,A经历了什么?
当前持有锁的A执行await时,加入condition等待队列的尾部,因为本来就是在获取锁以后的操作,不需CAS;当A在队列头部,就是signal被唤醒时,如果唤醒成功,则会加入到同步队列CLH尾部,需要CAS;A排队到头部,被唤醒,看公平锁还是非公平锁,如果是非公平锁,可能还会保持一段时间饥饿,如果是公平锁,直接获取锁,直接跳转wait方法释放,直到把锁释放。

非公平锁,插入CLH队尾时,需要CAS竞争,通知CLH队头时,也要CAS竞争

image-20220305082139330

image-20220305082139330

9,个性化定制方面,AQS是使用模板方法模式,可以自定义lock。
lock下的读写锁ReentrantReadWriteLock,能支持并发读,是共享排他锁。
AQS的可重写方法:tryacquireShared /tryreleaseshared,tryacquire/tryrelease等,不展开了

每个对象都与一个monitor 相关联。当且仅当拥有所有者时(被拥有),monitor才会被锁定。执行到monitorenter指令的线程,会尝试去获得对应的monitor,如下:

每个对象维护着一个记录着被锁次数的计数器, 对象未被锁定时,该计数器为0。线程进入monitor(执行monitorenter指令)时,会把计数器设置为1.

当同一个线程再次获得该对象的锁的时候,计数器再次自增.

当其他线程想获得该monitor的时候,就会阻塞,直到计数器为0才能成功。

锁升级详解

synchronized锁有四种状态,无锁,偏向锁,轻量级锁,重量级锁

无锁,偏向锁,轻量级锁(CAS)都是在用户空间完成
重量级锁是需要向内核申请的

简单的讲(简单概括这里可忽略不用看):
当线程A启动后,获得了对象的锁,此时线程A的线程ID将保存到对象的对象头中,对象头中的偏向锁标志位变为1。此时如果第二个线程B想访问这个对象,线程A、B之间就存在一个竞争的关系,但是此时偏向锁是偏向于线程A的,即A是优先的,偏向锁将会升级为轻量级锁,以此来保证持有对象锁的的线程A运行。此时线程B将会进行CAS,CAS也叫做自旋锁,B会去检查对象的锁是否还是属于A的,如果是,那B将会继续自选,直到对象锁被释放,B马上就会获取对象的锁。但是假如长时间无法获取到对象的锁,CAS是会消耗CPU的性能的,尤其当许多个线程竞争同一个对象的锁时,CPU资源占用会大大提高,此时锁将会再次升级,对象锁将升级为重量级锁。此时,所有竞争的线程将进入阻塞队列,等待cpu的调度。

32位

32位

64位

64位

看图,具体展开:
32位系统下,
当我们创建对象的时候,它对象头里Markword有25位hashcode,4位分代年龄,1位偏向锁标记,2位锁标记位。

对象头的其他,32位的指向元空间的类指针,不一定存在的32位对象数组长度,这里不展开。

如果对象进行new关键字,对象头有无25位hashcode?
仅仅new对象,如果没有显式或隐式调用hashcode方法,该标记位并没有真实标记hashcode。就是说,只有调用Object类hashcode,才会有标记,比如存入Hashmap,否则是0。

分代年龄,没有经过minorgc,就是0。

偏向锁位:
无锁状态,偏向锁位 0
偏向锁态,偏向锁位 1

无锁状态升级为偏向锁状态
想要成为偏向锁态,markword中无hashcode记录,这是一个前提。
让23位线程ID+2位epoch 占据了无锁态的所谓25位hashcode位。

锁标志位:
标记是什么锁状态。具体看图

偏向锁:
单线程操作环境,不存在锁竞争,偏向锁有一个非常好的性能,偏向锁会直接往markword里贴上线程ID,表示锁住了,每次只需检查线程ID是否偏向自己,好处是:没有CAS自旋和操作系统调用这些耗时的锁竞争机制
如果出现其他线程竞争,也不一定升级锁。比如线程A在对象已贴上线程ID,线程B发现被贴上A线程ID,就会检查线程A的存活状态,如果线程A已在临界区外,线程B先把对象置为匿名偏向锁状态,‘宁为玉碎,不为瓦全’,再准备CAS无锁竞争,如果竞争成功,线程B把对象置为偏向自己。如果竞争失败,比如线程C进来并且CAS抢到(截胡),C正在执行同步代码,此时B仍不放弃,还要做一个更高级的争取操作,线程B会进行偏向锁撤销的stw复杂操作:B线程挂起,等待线程C到达全局安全点后就把C暂停,线程C的栈会被遍历,找里边的偏向锁对象记录Lock Record,看看线程C是否还锁着。如果找到Lock Record则说明线程C还在临界区,否则线程C不存活。接下来,
简化地说:线程B撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
具体而言:
如果线程C还存活且线程正在执行同步代码块中的代码,则直接升级为轻量级锁
如果线程C未存活,或者未执行同步代码块中的代码,则进行校验是否触发过批量重偏向:
1 没触发重偏向,则撤销偏向锁,将markword升级为轻量级锁(无锁状态),再进行CAS竞争真正成为轻量级锁
2 触发了重偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向新线程。

那么,最后就会导致3种情况:

一是对象升级为轻量级锁,但起初只是锁位标记00,前30位置为00,没有指向锁记录栈帧,此时算是无锁状态;后续再CAS自旋竞争锁处理真正成为轻量级锁。

二是对象重新偏向到线程B。

涉及到批量重偏向批量撤销的操作。
除了对象markword有epoch计数器,class信息中也有epoch值,class的epoch维护两个阈值,批量重偏向阈值是20,批量撤销阈值40。

比如:同一个类创建40对象,线程1对40对象进行同步块的初始的加锁并保持执行,线程2对40个对象操作而引起了偏向锁撤销,首先会把前19个对象进行偏向锁撤销为匿名偏向锁,每次撤销class的epoch会+1,之后达到class批量重偏向阈值20,JVM认为class偏向锁有倾错问题,触发批量重偏向,后20到40对象通过CAS直接偏向线程B,这就是批量重偏向的过程,这里class计数器没有增长。线程3,执行20-40做锁撤销,撤销class计数器继续增长,达到class批量撤销阈值40,JVM这个类的竞争激烈,标记该class为偏向不可用,在之后创建41个对象时,会直接把锁标记置00膨胀为轻量级锁,这就是批量撤销过程。

此外,触发批量重偏向阈值20后,还配合一个time超时阈值25s,如果超时未达40的批量撤销阈值,重置class的epoch,下次重新计数20-39的范围。

三是直接将该类置为不可使用偏向锁。
这个就是批量撤销开启之后。

偏向锁升级为轻量级锁:
先把偏向锁标记置00,线程ID+Epoch置为00,是无所状态。由下一次争抢完成升级。

下面是流程图和代码,跟文字描述会有出入

偏向锁

偏向锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
static BiasedLocking::Condition revoke_bias(oop obj, bool allow_rebias, bool is_bulk, JavaThread* requesting_thread) {
markOop mark = obj->mark();
// 如果对象不是偏向锁,直接返回 NOT_BIASED
if (!mark->has_bias_pattern()) {
...
return BiasedLocking::NOT_BIASED;
}

uint age = mark->age();
// 构建两个 mark word,一个是匿名偏向模式(101),一个是无锁模式(001)
markOop biased_prototype = markOopDesc::biased_locking_prototype()->set_age(age);
markOop unbiased_prototype = markOopDesc::prototype()->set_age(age);

...

JavaThread* biased_thread = mark->biased_locker();
if (biased_thread == NULL) {
// 匿名偏向。当调用锁对象原始的 hashcode() 方法会走到这个逻辑
// 如果不允许重偏向,则将对象的 mark word 设置为无锁模式
if (!allow_rebias) {
obj->set_mark(unbiased_prototype);
}
...
return BiasedLocking::BIAS_REVOKED;
}

// 判断偏向线程是否还存活
bool thread_is_alive = false;
// 如果当前线程就是偏向线程
if (requesting_thread == biased_thread) {
thread_is_alive = true;
} else {
// 遍历当前 jvm 的所有线程,如果能找到,则说明偏向的线程还存活
for (JavaThread* cur_thread = Threads::first(); cur_thread != NULL; cur_thread = cur_thread->next()) {
if (cur_thread == biased_thread) {
thread_is_alive = true;
break;
}
}
}
// 如果偏向的线程已经不存活了
if (!thread_is_alive) {
// 如果允许重偏向,则将对象 mark word 设置为匿名偏向状态,否则设置为无锁状态
if (allow_rebias) {
obj->set_mark(biased_prototype);
} else {
obj->set_mark(unbiased_prototype);
}
...
return BiasedLocking::BIAS_REVOKED;
}

// 线程还存活则遍历线程栈中所有的 lock record
GrowableArray<MonitorInfo*>* cached_monitor_info = get_or_compute_monitor_info(biased_thread);
BasicLock* highest_lock = NULL;
for (int i = 0; i < cached_monitor_info->length(); i++) {
MonitorInfo* mon_info = cached_monitor_info->at(i);
// 如果能找到对应的 lock record,说明偏向所有者正在持有锁
if (mon_info->owner() == obj) {
...
// 升级为轻量级锁,修改栈中所有关联该锁的 lock record
// 先处理所有锁重入的情况,轻量级锁的 displaced mark word 为 NULL,表示锁重入
markOop mark = markOopDesc::encode((BasicLock*) NULL);
highest_lock = mon_info->lock();
highest_lock->set_displaced_header(mark);
} else {
...
}
}
if (highest_lock != NULL) { // highest_lock 如果非空,则它是最早关联该锁的 lock record
// 这个 lock record 是线程彻底退出该锁的最后一个 lock record
// 所以要,设置 lock record 的 displaced mark word 为无锁状态的 mark word
// 并让锁对象的 mark word 指向当前 lock record
highest_lock->set_displaced_header(unbiased_prototype);
obj->release_set_mark(markOopDesc::encode(highest_lock));
...
} else {
// 走到这里说明偏向所有者没有正在持有锁
...
if (allow_rebias) {
// 设置为匿名偏向状态
obj->set_mark(biased_prototype);
} else {
// 将 mark word 设置为无锁状态
obj->set_mark(unbiased_prototype);
}
}

return BiasedLocking::BIAS_REVOKED;

注:每次进入同步块(即执行monitorenter)的时候都会以从高往低的顺序在栈中找到第一个可用的Lock Record,并设置偏向线程ID;每次解锁(即执行monitorexit)的时候都会从最低的一个Lock Record移除。所以如果能找到对应的Lock Record说明偏向的线程还在执行同步代码块中的代码。

批量重偏向批量撤销如何理解,我用转岗问题举例:
有40个员工(对象锁),3个部门(线程)。
1) 线程1: 1-40都入职部门1了(有偏向的线程1了)
2) 线程2:1-19都离职(锁撤销)后又跳槽到部门2,HR们看转岗一出一进麻烦,改为以后想活水就直接在员工档案上改部门吧,一年内一次活水机会,结果,20-40都快速活水了(可重偏向)(Thread Id直接换了) // 到达BiasedLockingBulkRebiasThreshold(20)次数后。
3) 线程3:不到一年,20-40又都离职(锁撤销)后又跳槽到部门3,HR们觉得转岗频率太高了,滚蛋,以后都玩完,转岗门槛拉高(设置为不可偏向状态,正在运行的锁对象会被撤销)// 到达BiasedLockingBulkRevokeThreshold(40)次数
4) 以后新入职的员工41,就规定了较高的转岗门槛(new出来就是轻量级锁)。只能等待各部门主动去要你。

PS: 活水是指依靠公司内部的转岗机制,实现跳槽换部门。

轻量锁
多线程竞争获取这个锁时,由于该锁已经是偏向锁,当发现对象头 MarkWord 中的线程 ID 不是自己的线程 ID,会CAS尝试获得锁,如果持有锁的线程在全局安全点检查时,不需要再使用该锁了则获取成功,程序继续执行,反之则获取锁失败,撤销偏向状态01变为无锁00,升级为轻量级锁,即自旋锁。

此时,将锁对象markWord中32位修改成指向自己线程栈中Lock Record的指针(CAS抢)执行在用户态,消耗CPU的资源。此时,锁标记位00.
JDK1.6引入了聪明的自适应自旋锁。能减少长任务的自旋次数。
他的自旋次数是会变的,我用大白话来讲一下,就是线程如果上次自旋成功了,那么这次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么这次自旋也很有可能会再次成功。反之,如果某个锁很少有自旋成功,那么以后的自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

轻量级锁的加锁过程:
线程A进入同步块,如果同步对象偏向锁状态为无锁状态,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,里边存储锁对象目前的Mark Word的拷贝,存放拷贝的地方称之为 Displaced Mark Word。然后轻量级加锁时,拷贝对象头中的Mark Word复制到锁记录中;
之后,虚拟机将使用CAS操作尝试将object的Mark Word中的32位更新为指向线程A的Lock Record的指针,并将线程A的Lock record里的owner指针指向object的markWord,意在完成线程A与object的绑锁。此时,线程A持有锁。
此时线程B进来尝试CAS更新,
如果更新成功,那么线程B就拥有了该对象的锁,并且对象Mark Word的锁标志位置为“00”,即表示此对象处于轻量级锁定状态。
如果这个更新操作失败了,JVM首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。
此时为了提高获取锁的效率,线程B会不断地循环去获取锁,尝试CAS, 这个循环是有次数限制的,比如10次,如果在循环结束之前CAS操作成功,那么线程B就获取到锁;
如果循环结束依然获取不到锁, 则线程B获取锁失败, 对象的MarkWord中的记录会被修改为指向互斥量(重量级锁)的指针。之后,锁标志的状态值变为10,然后线程B被挂起,后面来的线程也会直接被挂起。在这个状态下,未抢到锁的线程都会进入 Monitor,之后会被阻塞在 _WaitSet 队列中。

此时,线程A的displaced mark word和原来对象的markword不一样了。

解锁
也是通过CAS操作来进行的,如果对象的Mark Word仍然指向着线程A锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来

因为线程B把对象 markword改成重量级锁,那么线程A替换失败,那么就把自己栈桢的Displaced Mark Word拷贝到对象头的Monitor对象的header变量,并且Monitor对象的owner变量指向自己。保证displaced mark word不丢失。

重量级锁
CAS发生10次后,轻量级锁升级为重量级锁,把锁指针指向重量级锁Object的monitor。

在Java虚拟机(HotSpot)中,Monitor对象其实就是ObjectMonitor对象,这个对象是一个C++对象,定义在虚拟机源码中。

回顾Monitor机制。
场景:线程A获取锁后,调用wait,A被唤醒,继续执行释放了,A经历了什么?
wait,A加入等待队列WaitSet尾部,需要CAS;当A在队列头部,就是被唤醒,就会进行一轮线程竞争,如果竞争失败,会加入到锁的同步队列EntrySet尾部,需要CAS;当A排队到同步队列头部,被唤醒,看公平锁还是非公平锁,如果是非公平锁,可能还会保持一段时间饥饿,如果是公平锁,直接获取锁,直接跳转wait方法释放,直到把锁释放。当然,synchronized是非公平锁,公平锁情况并没有出现。

image-20220305082139330

image-20220305082139330

image-20220324082854210

image-20220324082854210

synchronized可重入

synchronized是可重入锁,那么它是如何实现可重入的呢?其实上面详细的过程已经说过了,这里再总结一下(之前的判断逻辑就省略掉了):

偏向锁:检查markWord中的线程ID是否是当前线程,如果是的话就获取锁,继续执行代码;
轻量级锁:检查markWord中指向lockRecord的指针是否是指向当前线程的lockRecord,是的话继续执行代码;
重量级锁:检查_owner属性,如果该属性指向了本线程,_count属性+1,并继续执行代码。

总结
synchronized的执行过程:

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

上面几种锁都是JVM自己内部实现,当我们执行synchronized同步块的时候jvm会根据启用的锁和当前线程的争用情况,决定如何执行同步操作;

在所有的锁都启用的情况下线程进入临界区时会先去获取偏向锁,如果已经存在偏向锁了,则会尝试获取轻量级锁,启用自旋锁,如果自旋也没有获取到锁,则使用重量级锁,没有获取到锁的线程阻塞挂起,直到持有锁的线程执行完同步块唤醒他们;

synchronized锁升级实际上是把本来的悲观锁变成了 在一定条件下 使用无锁(同样线程获取相同资源的偏向锁),以及使用乐观(自旋锁 cas)和一定条件下悲观(重量级锁)的形式。

偏向锁:适用于单线程适用锁的情况,如果线程争用激烈,那么应该禁用偏向锁。

轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似)

重量级锁:适用于竞争激烈的情况

锁优化

以上介绍的锁不是我们代码中能够控制的,但是借鉴上面的思想,我们可以优化我们自己线程的加锁操作;

锁消除

锁消除用大白话来讲,就是在一段程序里你用了锁,但是jvm检测到这段程序里不存在共享数据竞争问题,也就是变量没有逃逸出方法外,这个时候jvm就会把这个锁消除掉

我们程序员写代码的时候自然是知道哪里需要上锁,哪里不需要,但是有时候我们虽然没有显示使用锁,但是我们不小心使了一些线程安全的API时,如StringBuffer、Vector、HashTable等,这个时候会隐形的加锁。比如下段代码:

public void sbTest(){
    StringBuffer sb= new StringBuffer();
    for(int i = 0 ; i < 10 ; i++){
        sb.append(i);
    }

    System.out.println(sb.toString());
}

上面这段代码,JVM可以明显检测到变量sb没有逃逸出方法sbTest()之外,所以JVM可以大胆地将sbTest内部的加锁操作消除。

减少锁的时间

不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

减小锁的粒度

它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间(如ConcurrentHashMap、LinkedBlockingQueue、LongAdder);

锁粗化

大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度; 在以下场景下需要粗化锁的粒度:
假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

使用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写。

单例模式的原因

双重校验锁DCL ——double check lock

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SafeDoubleCheckedLocking {
private volatile static Instance instance;

public static Instance getInstance() {
if (instance == null) {
synchronized (SafeDoubleCheckedLocking.class) {
if (instance == null)
instance = new Instance();//instance为volatile,现在没问题了
}
}
return instance;
}
}

1.懒汉实现惰性加载资源。

  1. volatile修饰,设置内存屏障。错误说法:强制指令执行顺序,先分配内存空间,再创建对象,最后引用指向该对象。
    正确:在这行代码的new指令前加入StoreStore屏障,在new指令后加入StoreLoad屏障,保证new指令内部的三步:分配内存空间,创建对象,引用指向该对象的完成,才有外部读操作,也就是if空判断的读取。
    总体效果就是一个线程对volatile的读,优先于其他线程对volatile的写。
    挖重点,StoreLoad是全能屏障,总线会有Lock前缀指令,确保写操作的主存刷新以及CPU缓存失效。
  2. static修饰,定义属于类变量,保证单例
  3. 第一个判断null,过滤synchronized锁的性能消耗
  4. 同步块,设置临界区,同时间只允许一个线程完成对象的创建。
  5. 第二个判断null,防止多线程进入第一个判断null,而导致多对象的创建。

Q: 过去DCL不安全的原因?
JVM1.4前,允许指令混乱情况。final:一个线程构造函数为final修饰变量赋值,另一个线程可能获取final的初始化0值;volatile:只保证读可见性,不保证写的顺序

Q: 不用volatile,先临时变量,再赋值到单例变量,是否能解决指令重排?
不能,JVM只是潜在的reorder推手之一,CPU和缓存也会导致重排

Q: 新版本volatile如何保证DCL安全?
设置内存屏障,防止指令重拍,其中store-load屏障是保证读写顺序,实现volatile上的happens-before语义,即一个线程对volatile变量的写,先于其他线程对该变量的同时读。屏障的结果就是原来只保证volatile字段本身的可见性,现在保证本身以及所有相关字段的可见性,代价就是造成访问volatile字段的更大性能开销。

Q:是否可以用final实现DCL?
能。
a.对final字段的写必然先于其它线程装载该final字段的共享引用。(final字段必须被正确地赋值后其它线程才能读取到它)
b. 构造函数执行完毕后,对 final 字段的所有写以及通过这些 final 字段间接可及的变量变为“冻结”,所有在冻结之后获得对这个对象的引用的线程都会保证看到所有冻结字段的冻结值。(所有线程对final及其间接字段有一致的可见性)
c. 初始化 final 字段的写将不会与构造函数关联的冻结后面的操作一起重排序。(构造函数内部,对某个final字段而言,它的冻结点之前的操作必然先于冻结点之后的操作)

再来说一下final域的重排规则:

  1. 写final的重排规则:
    JMM禁止编译器把final域的写重排序到构造函数之外。
    在final域的写之后,插入一个StoreStore屏障。
    也就是说确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了。
  2. 读final的重排规则:
    在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。
    在读final域操作的前面插入一个LoadLoad屏障。
    也就是说确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
  3. 如果final域是引用类型,那么增加如下约束:
    在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
    就是确保在构造函数外把这个被构造对象的引用赋值给一个引用变量之前,final域已经完全初始化并且赋值给了当前构造对象的成员域,至于初始化和赋值这两个操作则不确保先后顺序。)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FinalWrapper<T> {
public final T value;
public FinalWrapper(T value) {
this.value = value;
}
}

public class Foo {
private FinalWrapper<Helper> helperWrapper;

public Helper getHelper() {
FinalWrapper<Helper> wrapper = helperWrapper;

if (wrapper == null) {
synchronized(this) {
if (helperWrapper == null) {
helperWrapper = new FinalWrapper<Helper>(new Helper());
}
wrapper = helperWrapper;
}
}
return wrapper.value;
}
}

Q: DCL是否是实现单例的最佳方案?是否有代替?
不是。DCL的目的是懒加载和提高性能,但现代JMM加强volatile保证可靠,但副作用是性能开销变大,且加上了同步块的开销。

完美方案:
推荐通过内部类实现惰性加载:
有一个大前提,JVM保证类的加载过程是线程互斥。
第一个调用getInstance,instance只被创建1次,且赋值给instance的内存已初始化完毕,避免reorder,此外,该方法第一次使用互斥机制,解决同步块带来的性能问题;
当然,只有第一次调用getInstance时才让instance加载,所以是惰性。

1
2
3
4
5
6
7
8
9
10
11
public class Singleton{      
private Singleton(){

}
private static class SingletonContainer{
private static Singleton instance = new Singleton();
}
public static Singleton getInstance(){
return SingletonContainer.instance;
}
}

参考:https://blog.csdn.net/doraeimo/article/details/5714239

线程池参数调优—雨露均沾,保绩效

通过压测,符合最终场景,调整参数。
如何进行初次压测时,线程池参数配置?
分两种场景:
一当前服务器,只为当前线程池服务。也就是说,线程池能够占用服务器的所有资源,比如Tomcat的IO池,并行Steam流。依据处理逻辑,是CPU密集还是IO密集。CPU密集就选CPU数+1,IO密集就选CPU数的2N+1,N越大,可以加2加3。根据一轮压测结果,做一个参数的合理调整。
二是存在微服务和多接口,比如一个场景:5个接口:2个重要,3个降级,高并发压力下就会对3个接口进行降级,保障2个重要尽可能分配到更多服务器资源。在一个重要接口里,需要创建线程池,提高单接口处理速度,这个时候,不能只看CPU核数,线程池设置参数需要具体一个依据:
首先,一个真正高并发项目,它的监控是非常完备的,首先拿到线上高并发压力下该服务所有接口的访问比例。比如刚提到5个接口,2000条请求打到服务里边,5个接口分别承载着不同比例的访问量,比如2:3:1:1:1。比如,在比3这一块,它有资格至少占用服务资源的3/8。它不能太大,否则占用过多而让其他线程无路可走。
那么此时,基本可确定maxPool最大线程数参数,占服务器线程资源的3/8。
至于整体服务器峰值最大承载多少线程,参照CPU核数以及QPS来说。比如QPS100,相当于1s内服务器内当前几个CPU线程能承载起100个请求线程,因为处理速度够快。所以,要衡量整体服务器最大QPS,即当前服务器的同一时刻活跃的线程数。当前活跃线程数可粗略理解为这是基于业务需求场景的资源数最大值。再根据3/8,可以确定接口最大分配线程参数的值,100*3/8约等于40。

接下来,考虑核心线程数。初次压测时,设置为与最大线程数一般大,看能否撑住。撑住就尝试调小到资源不浪费,否则撑不住,要对任务排队队列Q设置,Q是存储排队的任务。Q不推荐无界队列,这相当于最大线程数失效,违背设计线程池的初衷,而且高并发导致Q的无限积压而OOM。Q设置为有界队列,这个界的依据又是什么呢?在生产上,像抢购秒杀都会有一个高峰访问时段,那么在压测的时候,需要模拟一个生产上的高峰访问时段。比如1小时抢购活动,就要至少持续1小时的压测。在无Q等待情况压测,大概算一下在分布式环境下的一个节点1小时该接口能够承载的最大值,如进来10w,只处理8w,失败2w,2w适当上调50%作为有界队列上限,为了访问节点倾斜的问题,如果均匀可改为10%,如果有节点访问尖刺就要调到尖刺最高位左右。要知道,实际的队列上界一般很大。

这几个初次调整后,开始多轮压测,看是否达到性能需求目的。可适当调小线程数,避免浪费无用的线程资源,因为,后续要涉及接口降级和线程池导致高可用问题。一步步调到最好,这是最理想的。
但往往可能,没有达到预期,刚刚已经给3/8最大资源量,还不够。第一点,再次调高队列存储上限,前提是业务能够接受这种等待慢,先不要想增加同时线程数,而是通过任务空间缓冲,保持当前接口稳定运行,且不影响其他接口。第二点,不接受Q过大,想尽快处理任务,那么,要从单机单接口的代码的并发性调高,从吞吐量和RT入手,比如,DB访问是瓶颈,使用缓存;第三方接口是瓶颈,使用异步MQ处理;秒杀业务,开Gzip压缩和做静态页面。第三点,已竭尽所有,只能线程数适当调大一点。但不能太大,如果以当前接口利益为中心,而毁坏其他接口持有服务器资源的权利,这是比较糟糕的。所以,在对重要接口的线程数调大的同时,要对其他接口做降级处理,相当于降级接口去牺牲它的服务器资源,分摊给重要接口。
但其实,这做法不要做,应该向上级申请加节点,通过集群分摊。
因为,调大线程数,是让其他接口冒着风险。其他的接口设置线程数小了,很有可能走到抛异常的饱和策略,这样,它抛出异常比例升高,造成接口的熔断或降级。
这本来够用的服务资源,由于设置线程池参数不合理而导致异常熔断或降级,AC,年终绩效没有了,必背锅。线程数调得过大,这事更大,你占用了额外的资源,导致别的接口的请求积压。

至于,keepAlive空闲线程存活时间,只是复用性的优化,根据测试,看有无触及阻塞队列阈值,如果频繁触及,说明业务总需要非核心线程,适当调大存活时间。一般默认即可,因为我已经确定当前最大线程数绝不会影响到服务的其他接口资源,它的调整优化作用非常有限。

饱和策略,大多数业务抛异常,记录日志,熔断或降级处理,做重试机制。

最后,理解以上这些,那么我们可以合理配置hystrix或sentinel。

Q:线程的状态转化,源码探索?

待更新。。。