一 synchronized 关键字的作用
用于保证在 同一时刻 最多只有 一个线程 执行该段代码,以达到保证并发安全的效果。
粗略的解释原理就是:要执行 synchronized 关键字保护的代码段,那么线程必须获取一个独占锁,直到被保护的代码段执行完毕再释放这个独占锁,占用锁期间其他的线程都无法执行这段代码。
二 synchronized 的地位
- 是 Java 的关键字,被 Java 原生语言支持。
- 是 最基本的 互斥同步手段
三 synchronized 的两个用法
- 对象锁
- 方法锁(默认锁对象为 this 当前实例对象)
- 同步代码块锁(自己指定锁对象)
- 类锁
- 修饰静态方法
- 锁 class 对象
一般情况下,对象锁用 this,让多个线程去竞争持有同一把锁是最常用的场景。除非业务进一步复杂化,那么我们就需要用多个锁来处理,这个时候同步代码块就可以考虑用多个锁。
另一种方式是使用类锁,如果直接在类上面使用 synchronized 关键字,那么这个类下的所有实例都会被控制到。控制范围更广。Java 类可以有多个实例对象,但是只有一个 class 对象。所谓的类锁,其实是 Class 对象的锁。
类锁的用法:
- synchronized 加在 static 方法上
- synchronized (.class) 代码块
idea 调试的小技巧
image.png多线程环境下的调试技巧:在要被并发访问的代码上断点,执行debug,程序运行到这里的时候,右键 idea 上的断点图标,会看到调试模式有 all 和 thread 两种。默认的 all 会停止整个 JVM。
右下角切换 console 到 debugger,选择 Thread 选项,选项框里面就有自己命名的线程选择了,选中任意自己命名的选项,打开 evaluate,执行 this.getState() 方法就会得到这个线程的状态。
二 synchronized 关键字的性质
- 可重入
- 避免死锁、提升封装性
- 粒度是线程而不是调用
- 不可中断
- 一旦这个锁已经被别人获得了,如果我还想获得,就只能选择等待或者阻塞,知道别的线程释放这个锁。如果别人永远都不释放这个锁,那么我只能永远等待下去;相比之下,Lock类中的有一些锁拥有 可中断 的能力。第一点,如果我觉得我等待的时间太长了,有权中断现在已经获取到锁的线程的执行。第二点:如果我觉得我等待的时间太长了不想再等了,也可以退出。
四 原理
synchronized 本身其实也就是一个“加锁-执行代码-释放锁”的一个过程。
在 Java 对象头里面有一个字段,用来表示这个对象是否已经被上锁。
查看反编译代码:
进入锁和释放锁是基于monitor对象实现的。monitor对象主要有两个指令:monitorenter与monitorexit。enter与exit是一对多关系。执行到enter指令,就会尝试去获取对象锁。
$ javap -verbose CompileDemo.class
Classfile /D:/gitPro/Algorithm/src/com/gaop/meituan/CompileDemo.class
Last modified 2018-12-31; size 489 bytes
MD5 checksum 92930c9ce3077d955610c58669ad8a3a
Compiled from "CompileDemo.java"
public class com.gaop.meituan.CompileDemo
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #2.#20 // java/lang/Object."<init>":()V
#2 = Class #21 // java/lang/Object
#3 = Fieldref #4.#22 // com/gaop/meituan/CompileDemo.monito r:Ljava/lang/Object;
#4 = Class #23 // com/gaop/meituan/CompileDemo
#5 = Utf8 monitor
#6 = Utf8 Ljava/lang/Object;
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 insert
#12 = Utf8 (Ljava/lang/Thread;)V
#13 = Utf8 StackMapTable
#14 = Class #23 // com/gaop/meituan/CompileDemo
#15 = Class #24 // java/lang/Thread
#16 = Class #21 // java/lang/Object
#17 = Class #25 // java/lang/Throwable
#18 = Utf8 SourceFile
#19 = Utf8 CompileDemo.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Utf8 java/lang/Object
#22 = NameAndType #5:#6 // monitor:Ljava/lang/Object;
#23 = Utf8 com/gaop/meituan/CompileDemo
#24 = Utf8 java/lang/Thread
#25 = Utf8 java/lang/Throwable
{
public com.gaop.meituan.CompileDemo();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init> ":()V
4: aload_0
5: new #2 // class java/lang/Object
8: dup
9: invokespecial #1 // Method java/lang/Object."<init> ":()V
12: putfield #3 // Field monitor:Ljava/lang/Object ;
15: return
LineNumberTable:
line 9: 0
line 11: 4
public void insert(java.lang.Thread);
descriptor: (Ljava/lang/Thread;)V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=2
0: aload_0
1: getfield #3 // Field monitor:Ljava/lang/Object ;
4: dup
5: astore_2
6: monitorenter
7: aload_2
8: monitorexit
9: goto 17
12: astore_3
13: aload_2
14: monitorexit
15: aload_3
16: athrow
17: return
Exception table:
from to target type
7 9 12 any
12 15 12 any
LineNumberTable:
line 14: 0
line 16: 7
line 17: 17
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 12
locals = [ class com/gaop/meituan/CompileDemo, class java/lang/Thread, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "CompileDemo.java"
反编译出来的代码里,在 insert 方法中我们看到了 monitorenter 和对应的 monitorexit 指令。注意有两个退出指令,而仅有一个进入指令。因为退出指令不仅仅是我们手动编写代码释放锁,还有遇到异常自动释放锁也是数据要执行退出指令的地方。
关于这两个指令:
每个对象都与一个monitor关联。每次执行一次 enter 指令,计数器对应 +1,每次执行退出操作,计数器对应 -1。exit 指令用于释放当前锁的所有权,当 monitor 计数器减为 0 后,就表示这个锁已经被释放。
可重入原理
可重入的原理,就和前面提到的 monitor 计数器类似。对象本身有一把锁, JVM 会跟踪对象被加锁的次数:
- 线程第一次给对象加锁的时候,计数变为 1,每当这个相同的线程在此对象上再次获得锁时,计数会递增。
- 每当任务离开时,计数递减,当计数为 0 的时候,锁被完全释放。
可见性原理:Java 内存模型
程序在运行是,单个的分支线程会从主内存中拷贝出一份副本保存在本地内存中。这样做的目的是减少访问主存的次数,提升程序的运行效率。
两个线程间通信的步骤:
- 线程A更新主内存中要变更的数据。
- 线程B从主存中读取已经被更新后的数据。
这个过程由 JMM(Java 内存模型)控制。
当一个代码块被 synchronized 关键字修饰,在程序执行完毕后,被锁住的对象所做的任何修改,都要在锁释放之前将变更从线程内存写回主存。这边每次变更结束后,是不会存在线程内存与主内存中数据不一致的情况。
与之对应的,在线程访问代码,进入 synchronized 关键字修饰的代码块(即得到对象锁)后,会先访问主内存获取被锁定对象的数据。这样读到的数据,一定是最新的。从而保证了可见性。
五 synchronized 关键字的缺陷
- 效率低
- 锁的释放情况少(执行完锁定代码块或者遇到异常退出)
- 试图获得锁时不能设定超时时间
- 不能中断一个正在试图获得锁的线程
- 不够灵活(相比较而言读写锁更灵活)
- 加锁和释放锁的时机单一
- 每个锁仅有单一的条件(某个对象),可能不够
- 无法知道是够成功获取到锁
六 volitile 关键字
总结完关于 synchronized 关键字的信息后,再回过头来看一下另一个关键字 volitile。
volitile 关键字会保证共享数据的可见性
1 数据的可见性
与 synchronized 关键字做比较,我们还是利用 JMM 角度的分析:
sync 关键字的使用,会导致线程内存在每一次读取信息的时候从主内存同步到最新的值;而在写入信息完成后,将本地的更新信息刷新到主内存中。而又因为使用了sync 关键字,导致这个时间段内没有其他的线程对当前要操作的数据同时进行更新操作,只有当前获取到锁的线程执行完所有的内存同步操作并且释放锁以后,才会有新的线程去访问,这个时候的数据已经被前一个释放锁的线程更新了。所以完整地保证了数据的一致性和可见性。
volitile 关键字本身没有加锁操作,所以从这一点上来说它就无法保证数据的一致性。 在保证数据的可见性角度来说,volitile 关键字会在线程对共享数据进行更新操作的时候立刻执行刷新主内存数据的操作,并且还会让其他线程保存的本地信息失效,这时候其他的线程就不得不重新从主存读取新的数据保存到本地。
2 指令重排
因为 JVM 和 CPU 存在的潜在指令重排优化操作。所以在多线程环境中存在潜在的代码执行顺序被打乱的风险。
volitile 关键字会禁止指令重排的操作,这样就能在一定程度上保证代码执行的有序性。 当程序执行到 volitile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经执行,而且也对后续代码可见;其后的代码也肯定没有执行。
视频学习地址来自慕课:
https://www.imooc.com/learn/1086
网友评论