文章目录
使用方法
-
修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码
synchronized(this|object)
表示进入同步代码前要获得给定对象的锁synchronized(类.class)
表示进入同步代码前要获得 当前 class对象 的锁
-
修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象,
等价于synchronized(this|object)
; -
修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象,
等价于 synchronized(类.class)
。
class Test{
//锁住实例对象
public synchronized void test() {
}
}
//等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
//---------------- --------------------------------------------------------------------------------
class Test{
//锁住类对象
public synchronized static void test() {
}
}
// 等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
}
作用
- 原子性:所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 被
synchronized
修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。 - 可见性: 可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。 synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。
- 有序性:有序性值程序执行的顺序按照代码先后执行 synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
案例
看以下案例的时候,可以将案例一,二看为一组,案例三,四看为一组,案例五,六看为一组。
案例一
class Number{
public synchronized void a() {
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
输出:12 或 21
分析:两个线程获取的都是同一实例对象的锁,会互斥访问
案例二
class Number{
public synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
输出:2 1s 后 1
分析:两个线程获取的是不同实例对象的锁,可以同时进入两个方法。
案例三
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
输出:1s 后12, 或 2 1s后 1
分析:两个线程获取的都是同一类对象的锁,会互斥访问
案例四
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public static synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
输出:1s 后12, 或 2 1s后 1
分析:两个线程获取的都是同一类对象的锁,会互斥访问
案列五
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n1.b(); }).start();
}
输出:2 1s后1
分析:一个线程获取的是类对象锁,一个线程获取的是实例对象锁,可以同时进入两个方法。
案例六
class Number{
public static synchronized void a() {
sleep(1);
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
public static void main(String[] args) {
Number n1 = new Number();
Number n2 = new Number();
new Thread(()->{ n1.a(); }).start();
new Thread(()->{ n2.b(); }).start();
}
输出:2 1s 后 1
分析:一个线程获取的是类对象锁,一个线程获取的是实例对象锁,可以同时进入两个方法。
原理
synchronized实际上利用对象保证了临界区代码的原子性
,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断。
synchronized是一种可重入锁,以下将从偏向锁,轻量级锁,锁膨胀,重量级锁,自旋这几个方面来讲解。
在此之前,需要了解两个相关概念:对象头和管程。
Java对象头
以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;
数组对象
其中 Mark Word 结构为
所以一个对象的结构如下:
Monitor
Monitor被翻译为监视器或者说管程
每个java对象都可以关联一个Monitor,如果使用synchronized
给对象上锁(当锁膨胀变为重量级锁的时候),该对象头的Mark Word中就被设置为指向Monitor对象的指针
- 刚开始时Monitor中的Owner为null
- 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
- 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态/TIME_WAITING状态。
WaitSet中的线程会在Owner线程调用notify或者notifyAll时唤醒,进入EntryList等待获取锁;或者等待时间结束后进入EntryList。
注意:synchronized 必须是进入同一个对象的 monitor 才有上述的效果不加 synchronized 的对象不会关联监视器,不遵从以上规则
字节码层面分析
- 同步代码块采用monitorenter、monitorexit指令显式的实现。
- 同步方法则使用ACC_SYNCHRONIZED标记符隐式的实现。
同步代码块
static final Object lock=new Object();
static int counter = 0;
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
反编译后的部分字节码
# 取得lock的引用(synchronized开始了)
0 getstatic #2 <com/concurrent/test/Test17.lock>
# 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
3 dup
# 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
4 astore_1
# 尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加 1
5 monitorenter
6 getstatic #3 <com/concurrent/test/Test17.counter>
9 iconst_1
10 iadd
11 putstatic #3 <com/concurrent/test/Test17.counter>
# 从局部变量表中取得lock的引用,放入操作数栈栈顶
14 aload_1
# 将锁计数器减 1。一旦计数器为 0 锁随即就被释放。
15 monitorexit
# 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return
同步方法
public synchronized void add(){
i++;
}
这个标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1。
轻量级锁
轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。
轻量级锁对使用者是透明的,即语法仍然是synchronized
,假设有两个方法同步块,利用同一个对象加锁。
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
-
每次指向到synchronized代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference
-
让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word ,将Mark Word 的值存入锁记录中
-
如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态01,如下所示
-
如果cas失败,有两种情况
-
如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段
-
如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条 Lock Record 作为重入的计数
-
-
当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有重入,这时重置锁记录,表示重入计数减一
-
当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象
- 成功则解锁成功
- 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
锁膨胀和重量级锁
如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。
重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。
Q:重量级锁为什么开销大?
A:唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
- 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
- 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
即为对象申请Monitor锁,让Object指向重量级锁地址,然后自己进入Monitor 的EntryList 变成BLOCKED
状态
- 当Thread-0 推出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程
自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁
-
自旋重试成功的情况
-
自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势
。
在 Java 6 之后自旋锁是自适应
的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
Java 7 之后不能控制是否开启自旋功能
偏向锁
在轻量级的锁中,我们可以发现,如果同一个线程对同一个2对象进行重入锁时,也需要执行CAS操作,这是有点耗时滴,那么java6开始引入了偏向锁的东东,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了
竞争激烈时不适合使用偏向锁,jvm会判断是否使用。
偏向状态
一个对象的创建过程
-
如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值.
-
偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-
XX:BiasedLockingStartupDelay=0
来禁用延迟 -
注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中
撤销偏向锁
hashcode方法
测试 hashCode
:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode
的值了
使用虚拟机参数-XX:BiasedLockingStartupDelay=0
,确保我们的程序最开始使用了偏向锁!但是结果显示程序还是使用了轻量级锁。
public static void main(String[] args) throws InterruptedException {
Test1 t = new Test1();
t.hashCode();
test.parseObjectHeader(getObjectHeader(t));
synchronized (t){
test.parseObjectHeader(getObjectHeader(t));
}
test.parseObjectHeader(getObjectHeader(t));
}
输出结果
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
LockFlag (2bit): 01
其它线程使用对象,批量重偏向,批量撤销
在没有发生竞争的情况下,当另一个线程获取对象锁时,偏向锁就会上升为轻量锁。
(可以理解为本来是偏向Thread-1的,现在Thread-2获得了对象锁,就撤销了对Thread-1的偏向,变为轻量级锁)
批量重偏向
:如果超过20个对象对同一个线程如Thread-1撤销偏向时,那么第20个及以后的对象可以将撤销对Thread-1的偏向变为偏向Thread-2。
批量撤销
:如果线程撤销超过40次,jvm知道竞争太激烈,整个类的所有对象都会变为不可偏向,新建的对象也是不可偏向的。
调用 wait/notify
会使对象的锁变成重量级锁,因为调用Wait方法的锁一定是重量级锁
锁消除
public class MyBenchmark {
static int x = 0;
@Benchmark
public void a() throws Exception {
x++;
}
@Benchmark
// JIT 即时编译器
public void b() throws Exception {
Object o = new Object();
synchronized (o) {
x++;
}
}
}
在b()方法中,局部变量o不会被共享,加锁没有意义,JIT就会将锁去掉,执行的时候和a()一样。
锁粗化
synchronized(this) {
}
synchronized(this) {
}
synchronized(this) {
}
JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会给合并为一个锁,就是锁粗化,把一个锁给搞粗了,避免频繁多次加锁释放锁