Java多线程-Synchronized

1.Synchronized的作用

Synchronized是同步、在时间上一致的意思。能够保证在同一时刻最多只有一个线程执行该段代码,以达到保证并发安全的效果。

Synchronized可以保证数据的可见性,即一个线程执行一系列操作后,另一个线程获取到正确的结果。

2.Synchronized的两个用法

2.1 对象锁

2.1.1 方法锁

在方法签名上加锁,synchronized修饰普通方法,默认锁对象为当前实例对象this。

如果实例化多个Runnable接口的实现类,那么他们不会互相等待,因为synchronized默认锁对象是当前实例this,而他们现在已经是不同的实例对象了,自然this也不一样了。

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
public class SynchronizedPractice implements Runnable {

@Override
public void run() {
syn();
}

private synchronized void syn(){
try {
System.out.println(Thread.currentThread().getName()+"开始休眠");
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName()+"结束休眠,放弃锁");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
//这是两个线程同时访问一个对象的同步方法
SynchronizedPractice synchronizedPractice = new SynchronizedPractice();
Thread thread1 = new Thread(synchronizedPractice);
thread1.start();
Thread thread2 = new Thread(synchronizedPractice);
thread2.start();
}
}

输出结果:

1
2
3
4
Thread-0开始休眠
Thread-0结束休眠,放弃锁
Thread-1开始休眠
Thread-1结束休眠,放弃锁

2.1.2同步代码块锁

手动指定锁对象。也可以手动指定this对象为锁对象

为什么要手动指定呢?因为有可能在业务逻辑中多段代码都是不能同步执行的,但是他们之间又不互相干,所以要分别加锁,而不是全部只加一个锁。

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
public class SynchronizedPractice2 implements Runnable {

Object lock1 = new Object();
Object lock2 = new Object();

@Override
public void run() {
//synchronized (this){ 这个等同于在方法上加synchronized
synchronized (lock1){
System.out.println(Thread.currentThread().getName()+"得到lock1锁,开始休眠");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"结束休眠,马上放弃lock1锁");
}
synchronized (lock2){
System.out.println(Thread.currentThread().getName()+"得到lock2锁,开始休眠");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"结束休眠,马上放弃lock2锁");
}
}

public static void main(String[] args) {
SynchronizedPractice2 synchronizedPractice = new SynchronizedPractice2();
Thread thread1 = new Thread(synchronizedPractice);
thread1.start();
Thread thread2 = new Thread(synchronizedPractice);
thread2.start();

}
}

输出结果为:

1
2
3
4
5
6
7
8
Thread-0得到lock1锁,开始休眠
Thread-0结束休眠,马上放弃lock1锁
Thread-1得到lock1锁,开始休眠
Thread-0得到lock2锁,开始休眠
Thread-1结束休眠,马上放弃lock1锁
Thread-0结束休眠,马上放弃lock2锁
Thread-1得到lock2锁,开始休眠
Thread-1结束休眠,马上放弃lock2锁

2.2 类锁

类锁是对于Class对象的锁。

一个java类可以有多个实例化对象,但是他们本质都是这个java类,即只有一个Class对象

类锁的形式:

  1. synchronized加在这个类的static方法上
  2. synchronized代码块

这样就算是这个类的不同的实例对象,类锁对他们都是有效的。

synchronized代码块的例子:

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
public class SynchronizedPractice3 implements Runnable {

@Override
public void run() {
synchronized (SynchronizedPractice3.class){
System.out.println(Thread.currentThread().getName()+"得到类锁,开始休眠");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"结束休眠,马上放弃类锁");
}
}

public static void main(String[] args) {
//这是两个线程同时访问两个对象的同步方法
SynchronizedPractice3 synchronizedPractice1 = new SynchronizedPractice3();
SynchronizedPractice3 synchronizedPractice2 = new SynchronizedPractice3();
Thread thread1 = new Thread(synchronizedPractice1);
thread1.start();
Thread thread2 = new Thread(synchronizedPractice2);
thread2.start();
}
}

输出结果:

1
2
3
4
Thread-0得到类锁,开始休眠
Thread-0结束休眠,马上放弃类锁
Thread-1得到类锁,开始休眠
Thread-1结束休眠,马上放弃类锁

方法抛出异常后会释放锁

3.Synchronized的性质

3.1 可重入

同一线程的外层函数获得锁之后,内层函数可以直接再次获取该锁。(一次获取,只要不放弃这个锁,可以多次利用)

好处:避免死锁,提升封装性。

解释:假设方法一被synchronized关键字修饰,方法二也被synchronized修饰,此时当线程一运行方法一拿到了这个锁,而在方法一中调用了方法二,假设不具备可重入,线程一想要去访问方法二需要使用这个锁,但是线程一不能直接使用本身已经获得的这个锁,可是线程一已经拿到了这个锁,所以这里线程一既想获得这个锁,又释放不了它,所以就死锁了。

所以可重入就避免了死锁和一次一次获取锁的操作。

原理:加锁次数计数器

jvm会记录被加锁的次数(锁对象被每一个线程获取了几次),当第一次加锁时,次数从0变为1,以后再加锁就依次加1,当退出一层同步代码块时,计数减一,当计数为0时,锁被释放。

3.2 不可中断

当一个线程获得了这个锁,其他线程必须等待它释放这个锁后才能使用它。

4.加锁和释放锁

4.1 时机

获取锁和释放锁的时机:进入和退出由synchronized修饰的方法和同步代码块(包括抛出异常)

4.2 原理

使用monitorenter获得锁,monitorexit释放锁。有两个monitorexit是因为一个是正常释放锁,一个是抛出异常后释放锁,jvm虚拟机会检查是哪种情况。

1
2
3
4
5
6
7
public class parctice {
public void insert(){
synchronized (this){

}
}
}

反编译的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void insert();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return

5.Synchronized的缺点

效率低:锁的释放情况少,视图获得锁时无法设定超时,不能中断一个正在试图获得锁的线程。

不够灵活:加锁和释放的时机单一,每个锁仅有单一的条件,就是这个锁是哪个对象的锁

无法知道是否成功获取到锁

6.原理

6.1 java对象结构

java对象(Object实例)结构包括三个部分:对象头对象体对齐字节

6.1.2 对象头

对象头的结构:

  1. Mark Word
  2. Class Pointer
  3. Array Length(这个只有数组对象才有,普通对象就只有上面两个)

Mark Word

作用:用于存储自身运行时的数据,例如GC标志位、哈希码、锁状态。

长度:与JVM的位数有关,Mark Word的长度为JVM的一个字的大小,即32位的JVM的Mark Word为32位,64位的JVM的Mark Word长度为64位。

下面我们都以64位的Mark Word为例:

java内置锁的状态有4种,级别由低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。

从低位到高位依次表示的是:

(1)lock:锁状态标记位,2bit

(2)biased_lock:对象是否启用偏向锁标记。1bit,为1表示启用了偏向锁,其他状态下该位均为0

lock和biased_lock共同决定了是否使用偏向锁,因为偏向锁状态和无锁状态的lock为都是01,无法判断是否使用偏向锁,因此当001是无锁,101是偏向锁。

(3)age:java的分代年龄,4bit,因此最大为15,并行GC的年龄阈值为15就会晋升到老年代。

(4)hashcode:对象的哈希码,31bit,采用延迟加载技术,刚开始创建对象的时候这个参数值为空,只有当调用了计算哈希的方法后才会被写入到对象头。

(5)thread:线程id,是拥有这个偏向锁的线程id

(6)ptr_to_lock_record:在轻量级锁状态下指向栈帧中锁记录的指针

(7)ptr_to_heavyweight_monitor:在重量级锁的状态下指向对象监视器的指针。每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级锁)后,,则对象会尝试找一个Monitor与之关联,关联成功,该对象头的Mark Word中就被设置指向Monitor对象的指针。


Class Pointer

作用:类对象指针,执行方法区中的类的klass对象,jvm通过这个指针确定这个对象是哪个类的实例。

长度:与JVM的位数有关,Class Pointer的长度为JVM的一个字的大小,即32位的JVM的Class Pointer为32位,64位的JVM的Class Pointer长度为64位。

因为64位的类对象指针会占据大量内存,因此一般对该字段会采取指针压缩的方法,即Oop对象指针压缩(可以手动关闭),压缩至32位。


Array Length

只有数组对象才有这个字段。

也会指针压缩

6.1.2 对象体

包含对象的成员变量。

6.1.3 对齐字节

填充字节,保证java对象所占用的内存字节数为8的倍数。

6.2 Monitor

Monitor被翻译为监视器管程。(操作系统层面的对象)

Monitor内部有WaitSet、EntryList(等待队列:希望获取Monitor锁,但是现在Monitor已经被其他线程所有,所以这个线程会进入这个等待队列,BLOCKED)、Owner(获得这个锁的线程指向它)

当拥有这个锁的线程把临界区代码执行完之后会释放这个锁,具体过程是按照Monitor地址找到Monitor对象,设置Owner为null,唤醒等待队列的线程,选一个线程拥有这个Monitor锁。

6.2 偏向锁

为什么要引入偏向锁?当一个同步代码块或者方法没有多个线程竞争,总是由一个线程多次重入申请获得锁,这样比较耗费性能。

偏向锁的核心原理:当锁对象第一次被线程获取时,

线程ID是由操作系统设置的,不是我们使用thread.getId得到的那个thread实例的id。

以后这个线程想要再进入同步块中,只需要判断锁对象中的线程ID和偏向标志位,就可以直接进入代码块中,不需要cas、进行申请锁等一系列操作。

如果线程ID不是当前线程的,有两种情况,1.线程ID为空

但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态,所以在4s之后创建的对象才是在匿名偏向状态,即偏向标志位为1,thread id为0

因为jvm在启动的时候需要加载资源,这些对象加上偏向锁没有意义,不起用偏向锁能减少大量偏向锁撤销的成本。

============分割线=============

我测试的偏向锁并没有延迟加载,现在可能是已经改了,都是立刻加载的,不过可以通过设置参数Run->Edit Configurations->VM options-> -XX:BiasedLockingStartupDelay=4000来开启偏向锁的延迟加载

撤销偏向

以下几种情况会使对象的偏向锁失效

  • 调用对象的hashCode方法
  • 其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁
  • 调用了wait/notify方法(调用wait方法会导致锁膨胀而使用重量级锁

批量重偏向

假设n个对象都是thread1的偏向锁对象,但是这n个对象被多个线程访问,但没有竞争,那其他线程访问时,会将偏向锁撤销,升级成轻量级锁。

  • 当撤销偏向锁的次数超过20次以后,这时剩下的偏向thread1的对象偏向给thread1的对象可能会重新批量重偏向给后来访问线程1的对象的线程。(因为每次撤销的消耗太大了)

  • 当撤销偏向锁的阈值超过40以后,就会将整个类的对象都改为不可偏向的(001),新建的对象也是不可偏向的。

6.3 轻量级锁

轻量级锁使用场景:当一个对象被多个线程所访问,但访问的时间是错开的(不存在竞争),此时就可以使用轻量级锁来优化。但是如果又有了竞争,轻量级锁会升级成重量级锁。优先使用轻量级锁。

轻量级锁对使用者是透明的,语法上还是使用synchronized代码块

使用原理:创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录对象,内部可以存储锁定对象的mark word。

让锁记录中的Object reference指向锁对象(Object),并尝试用cas(原子操作,线程安全)去替换Object中的mark word,将此mark word放入lock record中保存。如果cas替换成功,则将Object的对象头替换为锁记录的地址状态 00(轻量级锁状态),并由该线程给对象加锁。

所以替换成功的结果就是锁对象中的 lock record地址 00 与Object对象中的Make word交换。

锁重入就是该线程run方法中synchronized代码块中再次包裹synchronized代码块,即再次锁住这个对象。

如果一个线程在给一个对象加轻量级锁时,cas替换操作失败有两种情况:

  • 此时其他线程已经给对象加了轻量级锁的情况,此时该线程就会进入锁膨胀过程。
  • 自己执行了synchronized锁重入,那么会再加一条Lock Record对象,它的Object reference也指向这个object对象,线程中Lock Record对象的个数作为锁重入的计数。

每次锁重入的时候都需要cas,开销较大。所以引入了偏向锁。

只有第一次使用CAS将线程ID设置到对象的Mark word头中,之后如果发生了锁重入,发现这个线程ID是自己的就表示没有竞争,不用再cas,线程ID是由操作系统设置的,不是我们使用thread.getId得到的那个。

6.4 重量级锁

如果一个线程在给一个对象加轻量级锁时,cas替换操作失败(此时其他线程已经给对象加了轻量级锁的情况),此时该线程就会进入锁膨胀过程。

这个线程为Object申请Monitor锁,让Object的Mark word中保存Monitor地址,并指向Monitor,然后自己进入Monitor的EntryList中阻塞,当Thread0退出同步代码块时,需要解锁,尝试使用cas将Mark Word的值恢复给对象,失败,会进入重量级锁解锁流程。根据Object reference找到锁对象object,然后根据Mark word中的地址找到Monitor对象,设置Owner为null,唤醒等待队列的线程,选一个线程拥有这个Monitor锁。

在重量级锁竞争时,线程不会直接BLOCKED,而是先自旋重新尝试获得Monitor锁,如果在几次自旋后获得了锁,就开始执行,如果没有获得,就进入阻塞状态。

6.5 例子

创建完锁对象后,默认是匿名偏向状态。

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
@Slf4j
public class aaa {

@SneakyThrows
public static void main(String[] args) {
//设置开启偏向锁的延迟时间,4s
//Run->Edit Configurations->VM options-> -XX:BiasedLockingStartupDelay=4000

log.info("在开启之前创建的对象");
Dog dog1 = new Dog();
log.info(ClassLayout.parseInstance(dog1).toPrintable());

Thread.sleep(5000);

log.info("在开启之后创建的对象");
Dog dog2 = new Dog();
log.info(ClassLayout.parseInstance(dog2).toPrintable());

//LITTLE_ENDIAN:cpu采用小端序输出
//16进制中,两个数就是一字节
System.out.println(ByteOrder.nativeOrder());
}
}
class Dog {

}

小端:数据的高字节保存在内存的高地址中

大端:数据的高字节保存在内存的低地址中 //它们都是以字节为单位存放的

所以上面的数据是以小端的形式输出的,我们可以转换为正常习惯的方式,即大端来看。

对于第二个对象布局,首先,第一行和第二行是Mark Word,第三行是Class Pointer

Mark Word的大端形式(8B):00 00 00 00 00 00 00 05

转化为二进制就是 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000101,所以是匿名偏向状态,即可偏向,但是还没有线程偏向于这个锁对象。


线程A先获得了锁对象,则锁对象偏向于它,这时再来一个线程B竞争,锁升级为轻量级锁

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
@Slf4j
public class aaa {

@SneakyThrows
public static void main(String[] args) {

Dog dog3 = new Dog();
log.info(ClassLayout.parseInstance(dog3).toPrintable());

Runnable runnable = () -> {
synchronized (dog3){
log.info(Thread.currentThread().getName()+"获取到了锁对象");
log.info(ClassLayout.parseInstance(dog3).toPrintable());
try {
Thread.sleep(105);
} catch (InterruptedException e) {
e.printStackTrace();
}
log.info(ClassLayout.parseInstance(dog3).toPrintable());
}
};
new Thread(runnable).start();

Thread.sleep(100);

Runnable runnable2 = () -> {
synchronized (dog3){
log.info(Thread.currentThread().getName()+"获取到了锁对象");
log.info(ClassLayout.parseInstance(dog3).toPrintable());
}
};
new Thread(runnable2).start();
}
}
class Dog {

}


这里测试了好几次…..因为在锁对象状态为偏向锁的时候,有另外一个线程B来竞争锁对象,此时锁就会升级为轻量级锁,但是只要线程A不放弃锁,锁就还是它的,线程B会进行自旋,尝试获取锁,默认情况下自旋的次数为10次,如果超过10次还没有获得锁,就进行轻量级锁膨胀为重量级锁。

但是这个自旋时间也太不好把握了,稍微超过了自旋时间还没获得锁,就变成重量级锁了…..测试了半天才有一个000

轻量级锁有两种:普通自旋锁,自适应自旋锁

1.普通自旋锁:当有线程来竞争锁时,假如此时锁已经被另一个线程占用,这个抢锁线程就会在原地循环等待,而不会被阻塞,多次尝试获得锁,默认自旋次数为10次

2.自适应自旋锁:自旋次数不是固定的,而是根据1.如果抢锁线程在同一个锁对象上曾经获得过锁,那jvm就认为这次自旋很可能再次成功,因此自旋时间会更长,2.如果对于某个锁,抢锁线程很少成功获得过,那jvm就可能减少自旋时间甚至不自旋,以节约资源。

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
@Slf4j
public class aaa {

@SneakyThrows
public static void main(String[] args) {

Dog dog3 = new Dog();
log.info(ClassLayout.parseInstance(dog3).toPrintable());

Runnable runnable = () -> {
synchronized (dog3){
log.info(Thread.currentThread().getName()+"获取到了锁对象");
log.info(ClassLayout.parseInstance(dog3).toPrintable());

//log.info(ClassLayout.parseInstance(dog3).toPrintable());
}
};
new Thread(runnable).start();

Thread.sleep(100);

Runnable runnable2 = () -> {
synchronized (dog3){
log.info(Thread.currentThread().getName()+"获取到了锁对象");
log.info(ClassLayout.parseInstance(dog3).toPrintable());
}
};
new Thread(runnable2).start();
}
}
class Dog {

}

这没有竞争,线程A先持有锁,接着释放锁之后线程B再持有

这里其实有可能是000 也可能是101


偏向锁膨胀的步骤:


Java多线程-Synchronized
https://vickkkyz.fun/2022/04/14/Java/JUC/synchronized/
作者
Vickkkyz
发布于
2022年4月14日
许可协议