并发编程-synchronized
本篇讲述的是java 关键字 synchronized 使用以及原理,话不多说,我们直接进入正题
什么是线程安全性
线程安全性是指多个线程对共享的数据进行访问和操作时,不会出现不一致或不可预料的结果。在多线程境下,如果多个线程同时访问共享数据,可能会导致数据的不一致性或错误的结果,这种问题称为线程安全问题。为了保证线程安全性,需要采用合适的并发控制手段来保护共享数据的访问和操作。
影响线程安全性的原因
影响线程安全的三大特性是原子性、可见性和有序性。
- 原子性(Atomicity):指的是一个操作是不可中断的。即使在多线程环境下,某个操作要么全部执行完毕,要么完全不执行。原子性操作可以使用锁 synchronized 或者原子类(Atomic classes)来实现。
- 可见性(Visibility):指的是当一个线程对共享变量进行修改后,在没有额外同步操作的情况下,其他线程能够立即看到这个修改。可见性问题可以通过使用volatile关键字、synchronized关键字或者显式的锁机制来解决。
- 有序性(Ordering):指的是程序的执行按照一定的顺序来保证结果的正确性。在单线程环境下,操作是按照程序的编写顺序来执行的,但在多线程环境下,线程的执行顺序是不确定的。为了保证有序性,可以使用synchronized关键字、volatile关键字、显示的锁机制或者并发工具类(如CountDownLatch、Semaphore)来确保正确的执行顺序。
原子性问题
我们先通过代码来演示一下原子性问题
public class UnsafeDemo {
private int i = 0;
private void incr(){
i++;
}
public static void main(String[] args) {
UnsafeDemo unsafeDemo = new UnsafeDemo();
Thread[] threads=new Thread[2];
for (int j = 0;j<2;j++) {
// 创建两个线程
threads[j]=new Thread(() -> {
// 每个线程跑1000次
for (int k=0;k<1000;k++) {
unsafeDemo.incr();
}
});
threads[j].start();
}
try {
//让两个线程执行完
threads[0].join();
threads[1].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(unsafeDemo.i);
}
}
以上代码中我们模拟了两个线程各跑一千次 i++ 理论上我们最后得到的结果是2000 ,但实际运行之后我们发现结果有时候是2000,有时是其他结果。这是因为原子性导致线程不安全问题,那么问题来了,导致它的原因是什么,换句话说为啥会出现这个问题。那这得从cpu来说起
简单了解cpu的构造
CPU从逻辑上可以划分成3个模块,分别是控制单元、运算单元和存储单元,这三部分由CPU内部总线连接起来。如下所示
控制单元
控制单元是整个CPU的指挥控制中心,由指令寄存器IR(Instruction Register)、指令译码器ID(Instruction Decoder)和操作控制器OC(Operation Controller)等,对协调整个电脑有序工作极为重要。它根据用户预先编好的程序,依次从存储器中取出各条指令,放在指令寄存器IR中,通过指令译码(分析)确定应该进行什么操作,然后通过操作控制器OC,按确定的时序,向相应的部件发出微操作控制信号。操作控制器OC中主要包括节拍脉冲发生器、控制矩阵、时钟脉冲发生器、复位电路和启停电路等控制逻辑。
运算单元
是运算器的核心。可以执行算术运算(包括加减乘数等基本运算及其附加运算)和逻辑运算(包括移位、逻辑测试或两个值比较)。相对控制单元而言,运算器接受控制单元的命令而进行动作,即运算单元所进行的全部操作都是由控制单元发出的控制信号来指挥的,所以它是执行部件。
存储单元
包括CPU片内缓存和寄存器组,是CPU中暂时存放数据的地方,里面保存着那些等待处理的数据,或已经处理过的数据,CPU访问寄存器所用的时间要比访问内存的时间短。采用寄存器,可以减少CPU访问内存的次数,从而提高了CPU的工作速度。但因为受到芯片面积和集成度所限,寄存器组的容量不可能很大。寄存器组可分为专用寄存器和通用寄存器。专用寄存器的作用是固定的,分别寄存相应的数据。而通用寄存器用途广泛并可由程序员规定其用途,通用寄存器的数目因微处理器而异。
简单来说就是运算单元 负责算术运算(+ - * / 基本运算和附加运算)和逻辑运算(包括 移位、逻辑测试或比较两个值等)。控制单元 则高级一点,负责应对所有的信息情况,调度运算器把计算做好。存储单元(寄存器) 就稍微复杂一点,既要对接控制器的命令,传达命令给运算器;还要帮运算器记录处理完或者将要处理的数据。
原子性问题的本质
既然我们已经初步了解了CPU的构造。也知道了它的三个主要部分是干什么的,那么我们就要回归到问题上来,探究原子性问题的本质。对于i++ 我们从CPU层面可以简单理解为:
(1)先读取到i的值,放到存储单元(寄存器)中。
(2)通过控制单元调度 运算单元计算i++。
(3)运算单元将计算完的结果写入内存。
大致描述一下,过程可能不是那么的准确,每一步相当于一个指令 。通俗的理解为:先读取到一个值把这个值存起来,然后做计算,最后把结果输出这么一个过程。
众所周知,一个CPU核心在同一时刻只能执行一个线程,如果线程数量远远大于CPU核心数,就会 发生线程的切换,这个切换动作可以发生在任何一条CPU指令执行完之前,也就是以上这几个步骤中间会存在线程的切换。我们通过一张图来加深一下理解
这就是为什么之前执行了1000次的i++,实际得到值跟预期得到值不一致,这也就是原子性问题的本质
synchronized 关键字
已经知道了问题的所在,也清楚了问题出现的来龙去脉,那怎么解决呢,我们引入synchronized 关键字(同步锁),保证在同一时刻只能由一个线程来访问。代码如下
public class UnsafeDemo {
private int i = 0;
private synchronized void incr(){
i++;
}
public static void main(String[] args) {
UnsafeDemo unsafeDemo = new UnsafeDemo();
Thread[] threads=new Thread[2];
for (int j = 0;j<2;j++) {
// 创建两个线程
threads[j]=new Thread(() -> {
// 每个线程跑1000次
for (int k=0;k<1000;k++) {
unsafeDemo.incr();
}
});
threads[j].start();
}
try {
threads[0].join();
threads[1].join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(unsafeDemo.i);
}
}
运行代码 无论执行多少次 结果始终是2000
synchronized 作用范围
修饰实例方法
synchronized 修饰的是非静态类的方法,作用范围是当前方法。作用对象是当前实例对象。代码如下
/**
* synchronized的加锁范围
*/
public class SynchronizedDemo {
/**
* 修饰实例方法
*/
public synchronized void method1(){
while(true){
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("synchronized修饰实例方法锁的是当前实例对象的方法:"+ Thread.currentThread().getName());
}
}
public static void main(String[] args) {
// 加锁一定会带来性能开销,
SynchronizedDemo s1=new SynchronizedDemo();
SynchronizedDemo s2=new SynchronizedDemo();
new Thread(()->{
s1.method1();
},"线程A").start();
new Thread(()->s1.method1(),"线程B").start();
}
}
当两个线程调用同一个对象中的 method1() 方法时我们会发现,线程A持有锁,线程A在不断的执行
当两个线程调用不同对象中的 method1() 方法时我们会发现,两个线程交替执行,不受影响。所以得出结论修饰实例方法锁的是当前的实例对象
public static void main(String[] args) {
// 加锁一定会带来性能开销,
SynchronizedDemo s1=new SynchronizedDemo();
SynchronizedDemo s2=new SynchronizedDemo();
new Thread(()->{
s1.method1();
},"线程A").start();
new Thread(()->s2.method1(),"线程B").start();
}
修饰代码块
synchronized 修饰代码块与修饰实例方法作用的对象都是当前实例对象。只不过修饰代码块进一步缩小了锁的范围。代码如下
/**
* synchronized的加锁范围
*/
public class SynchronizedDemo {
/**
* 修饰代码块
*/
public void method2(){
//代码块
synchronized (this){
while (true){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("锁住的是代码块中的内容:"+Thread.currentThread().getName());
}
}
}
public static void main(String[] args) {
// 加锁一定会带来性能开销,
SynchronizedDemo s1=new SynchronizedDemo();
SynchronizedDemo s2=new SynchronizedDemo();
new Thread(()->{
s1.method2();
},"线程A").start();
new Thread(()->s1.method2(),"线程B").start();
}
}
运行结果
修饰静态方法
synchronized 修饰静态方法作用范围是当前的静态方法,作用对象是类锁(即Class本身,注:不是通过 new() 的对象)也就是说不管new() 了多少个对象,还是用的同一把锁。代码如下
/**
* synchronized的加锁范围
*/
public class SynchronizedDemo {
/**
* 修饰静态方法
*/
public synchronized static void method3(){
while(true){
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("synchronized修饰静态方法是类锁:"+Thread.currentThread().getName());
}
}
public static void main(String[] args) {
// 加锁一定会带来性能开销,
SynchronizedDemo s1=new SynchronizedDemo();
SynchronizedDemo s2=new SynchronizedDemo();
//注意 这个地方调用静态方法在idea中会报红,只是不提倡这种静态方法的调用方式。不影响运行结果,为了体现运行结果才这样调用
new Thread(()->{
s1.method3();
},"线程A").start();
new Thread(()->s2.method3(),"线程B").start();
}
}
运行结果
synchronized 原理
我们已经知道了如何使用synchronized 关键字,也了解了其作用范围,那么问题来了,synchronized 干了什么,怎么就能解决原子性问题?这个问题我们可以大胆的设想一下,要是我们自己来实现锁怎么实现呢?秉着大道至简的做法通常我们会设定一个状态status 当 status = 0 的时候代表无锁,status = 1代表有锁。
那synchronized 大致是不是这么做的呢,如果是的话 状态又应该怎么存,放到什么地方。这时候需要引出对象头的概念
Markword 对象头
在HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
这里我们先主要关注mark-word:对象标记字段占4个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标 记位,偏向锁标记位、分代年龄等。 了解这些信息后我们还是没接触到真相,那我们能不能看到具体的对象头信息呢?当然可以,我们将对象头信息打印出来
引入包
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
执行代码
public class MarkWordDemo {
public static void main(String[] args) {
MarkWordDemo markWordDemo = new MarkWordDemo();
System.out.println(ClassLayout.parseInstance(markWordDemo).toPrintable());
}
}
打印结果
com.example.demo.ThreadExample.MarkWordDemo 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) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
加锁之后的结果我们可以发现前两行的value是不一样的,也就说加锁和不加锁的区别就在前两行里,那我们就重点看前两行。关于锁标志位这里有这么一张图
这张图表示的是对象头里面锁标志位,还有锁的状态等等,说到这里我们就得从对象头里先跳出来,得补充一下synchronized 锁升级的概念,这样更有助于理解对象头里面锁标志位
synchronized 锁升级
在JDK1.6之前 synchronized锁要么是无锁,要么就是重量级锁。锁的操作性能开销大,基于安全性和性能之间的平衡在JDK1.6之后synchronized锁做了优化。减少了重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问题。在JDK1.6之后锁的主要状态有以下几种
-
无锁
-
偏向锁
偏向锁就是在运行过程中,对象的锁偏向某个线程。即在开启偏向锁机制的情况下,某个线程获得锁,当该线程下次再想要获得锁时,不需要再获得锁 (这个地方可以简单的理解为我们去一个景区游玩,买了个年票。那下次再去的时候就不用去买票了直接进景区游玩)。
-
轻量级锁
轻量级锁不是用来替代传统的重量级锁的,而是在没有多线程竞争的情况下,使用轻量级锁能够减少性能消耗,但是当多个线程同时竞争锁时,轻量级锁会膨胀为重量级锁
-
重量级锁
我们用一个图和一段文字来描述一下锁升级的概念
假设有两个线程 线程A和线程B,线程A和线程B去抢占锁。
- 当线程A获取到偏向锁时,线程B会进行自旋操作(自旋可以类比为一个for循环,不断尝试去获得锁)
- 此时线程B在不断的尝试获取锁,当然也不是一直自旋,线程B肯定有次数限制。在次数限制之内线程A由偏向锁升级为轻量级锁。
- 当线程B获取锁失败就会阻塞并加入等待队列(也就是常说的 LockSupport.park())。此时线程A完全获取到锁,由轻量级锁升级为重量级锁
我们刚理解了锁升级是怎么一回事,也知道对象头锁标志位是什么。接下来就是要将二者结合起来,探究synchronized 原理的真谛
原理的验证
我们把思绪拉回到锁标志位的那个图,在每一个锁状态的最后都有一个锁标志位,我们可以看到当锁的状态是无锁时 偏向锁位和锁标志位合起来是 001 轻量级锁时是 000。看到这时我们会联想到这个 001和000是不是跟打印对象头的信息能关联起来,我们在回到无锁状态下对象头信息
com.example.demo.ThreadExample.MarkWordDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
//我们找到了关联所在 001 无锁状态 00000001 取后三位
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) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
这时会产生个疑问,有锁状态下打印的对象头信息是000 也就是轻量级锁,但这里边并没有其他线程来竞争,偏向锁去哪了?
默认情况下,偏向锁的开启是有个延迟,默认是4秒。为什么这么设计呢? 因为JVM虚拟机自己有一些默认启动的线程,这些线程里面有很多的Synchronized代码,这些 Synchronized代码启动的时候就会触发竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁的升级和撤销,效率较低。那我们需要改以下配置,才能看到偏向锁
-XX:BiasedLockingStartupDelay=0
配置在idea更改
更改完之后,我们再来执行刚才有锁的代码
public class MarkWordDemo {
public static void main(String[] args) {
MarkWordDemo markWordDemo = new MarkWordDemo();
synchronized (markWordDemo){
System.out.println(ClassLayout.parseInstance(markWordDemo).toPrintable());
}
}
}
打印出对象头信息
com.example.demo.ThreadExample.MarkWordDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 c8 49 07 (00000101 11001000 01001001 00000111) (122275845)
4 4 (object header) 84 02 00 00 (10000100 00000010 00000000 00000000) (644)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
我们看到此时对象头锁标志位就是101,也就对应的是偏向锁。那重量级锁呢?对于重量级锁我们需要先模拟下线程的竞争
/**
* 模拟线程抢占
*/
public class MarkWordDemo {
public static void main(String[] args) {
MarkWordDemo markWordDemo = new MarkWordDemo();
Thread t1=new Thread(()->{
//如果当前是重量级锁.后面来的线程直接阻塞.
synchronized (markWordDemo){
System.out.println("t1 线程获取到锁");
System.out.println(ClassLayout.parseInstance(markWordDemo).toPrintable());
}
});
t1.start();
synchronized (markWordDemo){
System.out.println("main 线程获取到锁");
System.out.println(ClassLayout.parseInstance(markWordDemo).toPrintable());
}
}
}
打印结果
main 线程获取到锁
com.example.demo.ThreadExample.MarkWordDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 9a 86 57 e1 (10011010 10000110 01010111 11100001) (-514357606)
4 4 (object header) c6 01 00 00 (11000110 00000001 00000000 00000000) (454)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
t1 线程获取到锁
com.example.demo.ThreadExample.MarkWordDemo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 9a 86 57 e1 (10011010 10000110 01010111 11100001) (-514357606)
4 4 (object header) c6 01 00 00 (11000110 00000001 00000000 00000000) (454)
8 4 (object header) 05 c1 00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
我们看到锁标志位是010 这就是重量级锁
总结一下
稍微总结一下,做一个梳理。我们由一开始的猜测synchronized常规做法是设定一个状态status 有锁和无锁就是修改status的值(0和1)。为了验证猜测我们查看了对象头的信息,发现有锁和无锁状态下对象头信息是不一样的。为了能看懂这个对象头信息所表达的含义我们又引出了锁标志位和锁升级的概念,了解了无锁(000)——偏向锁(101)——轻量级锁(001)——重量级锁(010)它们之间是如何升级以及对应锁的标志位是怎么变化的,由此我们推断synchronized锁 和我们猜测的差不多,都是相当于改变状态来实现的。
关于CAS操作
CAS在整个并发编程源码中经常用到,但其底层是C++代码,这个我们无法去深入探究。所以先了解其用意也很重要
什么是CAS
CAS(compareAndSwap/compareAndExchange)也叫比较交换,是一种无锁原子算法,映射到操作系统就是一条cmpxchg硬件汇编指令(保证原子性),其作用是让CPU将内存值更新为新值,但是有个条件,内存值必须与期望值相同,并且CAS操作无需用户态与内核态切换,直接在用户态对内存进行读写操作(意味着不会阻塞/线程上下文切换)。
它的核心意思就是比较并替换的意思。CAS包含三个值当前内存值(V)、预期原来的值(E)以及期待更新的值(N)。
假设现在有一个变量int a=5;我想要把它更新为6,用cas的话,有三个参数cas(5,5,6),我们要更新的值是5,找到了a=5,符合V值,预期的值E=5符合,然后就会把N=6更新给a,a的值就会变成6。
我们还是通过代码来直观感受一下
public class AtomicIntegerDemo {
public static void main(String[] args) {
AtomicInteger atomicInteger = new AtomicInteger(5);
boolean isChange = atomicInteger.compareAndSet(5, 6);
int i = atomicInteger.get();
System.out.println("是否变化:"+isChange);
System.out.println(i);
}
}
运行结果
特别说明
由于synchronized核心内容牵扯到jvm底层代码,这个超出了作者的知识范畴。作者水平有限,在讲述整个内容的过程中涉及到一些更深层次的概念要么没有说明,要么一带而过,还请大家谅解。本意是为了将知识点进行串联,记得更牢,也方便理解(加入了些生活中场景,也许并不恰当。主要为了好理解)。