Java关键词synchronized解读

特别说明:
monitor在中文书籍有多种翻译,本文档统一使用:对象锁

书籍 中文翻译
《java编程思想(第4版)》 监视器、对象的锁
《深入理解Java虚拟机(第3版)》 对象的锁
《Java虚拟机规范(Java SE 8版)》 同步锁
《JAVA并发编程实践)》 内部锁、监视器锁
《Java并发编程之美》 内部锁、监视器锁
Oracle官方文档:java虚拟机指令集(英转中翻译) 监视器
彻底理解Java并发编程之Synchronized关键字实现原理剖析 管程、管程对象

1 引入synchronized

  1. synchronized是java虚拟机为线程安全而引入的。
  2. 互斥同步是一种最常见的并发正确性的保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。
  3. synchronized是最基本的互斥同步手段,它是一种块结构的同步语法。
  4. synchronized修饰代码块,无论该代码块正常执行完成还是发生异常,都会释放锁

synchronized对线程访问的影响:

  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会阻塞其他线程的进入。
  • 被synchronized修饰的同步块对同一条线程是可重入

2 synchronized的使用

java虚拟机支持方法级同步和方法内代码块同步

  1. 修饰方法,包括实例方法和静态方法
  2. 修饰方法内的代码块,这时需要一个引用类型数据作为参数

2.1 Synchronized修饰实例方法

Synchronized修饰实例方法时,多线程调用同一个对象的该实例方法是同步的,调用不同对象则不受同步约束。

public static void main(String[] args){
        SyncTest syncTest = new SyncTest();
        final Runnable runnable = () -> syncTest.doing(Thread.currentThread().getName()); //调用同一对象:syncTest的doing方法
        new Thread(runnable).start();
        new Thread(runnable).start();
    }

    //同一时刻只能被一个线程调用
    private synchronized void doing(String threadName){
        for(int i=0;i<3;i++){
            System.out.println("current thread is : "+threadName);
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {}
        }
    }

运行结果:

current thread is : Thread-0
current thread is : Thread-0
current thread is : Thread-0
current thread is : Thread-1
current thread is : Thread-1
current thread is : Thread-1

2.2 Synchronized修饰代码块

Synchronized修饰代码块,需要引用作为参数,如果参数为非"*.class",那么多线程调用同一个对象同步代码块是同步的,调用不同对象则不受同步约束。

public class SyncTest_2 {
    public static void main(String[] args){
        SyncTest_2 syncTest = new SyncTest_2();
        final Runnable runnable = () -> syncTest.doing(Thread.currentThread().getName());
        new Thread(runnable).start();
        new Thread(runnable).start();//2个线程尝试调用doing()方法
    }

    private void doing(String threadName){
        //同一时刻只能被一个线程调用
       synchronized (this){
            for(int i=0;i<3;i++){
                System.out.println("current thread is : "+threadName);
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {}
            }
        }

    }
}

运行结果:

current thread is : Thread-0
current thread is : Thread-0
current thread is : Thread-0
current thread is : Thread-1
current thread is : Thread-1
current thread is : Thread-1

2.3 synchronize修饰静态方法

synchronize修饰静态方法,多线程访问该类的所有对象的sychronized块是同步的

   public class SyncTest_3 {
    public static void main(String[] args){
        SyncTest_3 tempTest1 = new SyncTest_3();
        SyncTest_3 tempTest2 = new SyncTest_3();
        //虽然创建了两个SyncTest_3实例,但是依然是调用同一个doing方法;因此doing还是会依次执行
        new Thread(() -> tempTest1.doing(Thread.currentThread().getName())).start();
        new Thread(() -> tempTest2.doing(Thread.currentThread().getName())).start();
    }

    //修饰静态方法;
    private static synchronized void doing(String threadName){
        for(int i=0;i<3;i++){
            System.out.println("current thread is : "+threadName);
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {}
        }
    }
}

运行结果:有序输出 【如果去掉static ,则线程会交替执行doing】

current thread is : Thread-0
current thread is : Thread-0
current thread is : Thread-0
current thread is : Thread-1
current thread is : Thread-1
current thread is : Thread-1

2.4 synchronize修饰代码块且参数为*.class

效果和修饰静态方法一样。

public class SynchronizedObjectLock implements Runnable {
    static SynchronizedObjectLock instence1 = new SynchronizedObjectLock();
    static SynchronizedObjectLock instence2 = new SynchronizedObjectLock();

    @Override
    public void run() {
        // 所有线程需要的锁都是同一把
        synchronized(SynchronizedObjectLock.class){
            System.out.println("我是线程" + Thread.currentThread().getName());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(instence1);
        Thread t2 = new Thread(instence2);
        t1.start();
        t2.start();
    }
}

结果:

我是线程Thread-0
Thread-0结束
我是线程Thread-1
Thread-1结束

3 synchronized原理分析

3.1 虚拟机如何辨别和处理synchronized

不是所有synchronized同步都会生成monitorenter和monitorexit字节码指令!!!
不是所有synchronized同步都会生成monitorenter和monitorexit字节码指令!!!
不是所有synchronized同步都会生成monitorenter和monitorexit字节码指令!!!

java虚拟机支持两种同步方式:1、方法级的同步;2、方法内部代码块的同步,这两种同步结构都是使用对象锁(monitor) 来支持的。

3.1.1 方法级同步:隐式同步

synchronized修饰方法,它是隐式同步,并不是用monitorenter和monitorexit指令来实现的,而是由方法调用指令读取运行时常量池中方法的ACC_ SYNCHRONIZED 标志来隐式实现的。

public class SyncTest_4 {
    synchronized void tryYourBest(){
        System.out.println("try your best");
    }
}

编译后的字节码内容:

jvm如何处理方法级同步?

  1. 当调用方法时,调用指令将会检查方法的ACC_ SYNCHRONIZED访问标志是否设置,如果设置了,执行线程将先持有对象锁,然后执行方法,最后在方法完成时释放对象锁。
  2. 在方法执行期间,执行线程持有了对象锁,其他任何线程都无法再获得同一个锁。
  3. 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的锁将在异常抛到同步方法之外时自动释放。

3.1.2 代码块级同步:显式同步

synchronized修饰方法的代码块时,它是显式同步。会在同步代码块前后生成monitorenter和monitorexit指令。
以下代码:

public class Foo {
    void onlyMe(Foo f) {
        synchronized(f) {
            doSomething();
        }
    }
    private void doSomething(){ }
}

编译后,这段代码生成的字节码序列如下:

jvm如何处理代码块级同步?

  1. synchronized关键字经过Javac编译之后,会在同步块的前后生成monitorentermonitorexit两个字节码指令。
  2. 指令含义:monitorenter:获取对象锁monitorexit:释放对象锁
  3. 执行monitorenter指令时,首先尝试获取对象锁。如果对象没被锁定,或者当前线程已经持有了对象锁,就把锁的计数器的值增加1
  4. 执行monitorexit指令时,将锁计数器的值减1,一旦计数器的值为零,锁随即就被释放
  5. 如果获取对象锁失败,那当前线程阻塞等待,直到锁被释放。
  6. 为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理程序,它的目的就是用来执行monitorexit指令。

3.2 虚拟机执行加锁和释放锁的过程

1. 什么叫对象的锁?
对象的内存结构参考:2 Java内存层面的对象认识

  1. 锁,一种可以被读写的资源,对象的锁是对象的一部分。
  2. 对象的结构中有部分称为对象头
  3. 对象头中有2bit空间,用于存储锁标志,通过该标志位来标识对象是否被锁定。

以下基于轻量级锁的加锁和释放锁过程为例:

2. 如何确定锁被线程持有?

  1. 代码即将进入同步块的时,如果锁标志位为“01”(对象未被锁定),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录lock record的空间,存储锁对象Mark Word的拷贝。(线程开辟空间并存储对象头)
  2. 虚拟机将使用CAS操作尝试把对象的Mark Word更新成指向锁记录的指针(对象头的mw存储指向线程“锁记录”中的指针)
  3. 如果CAS操作成功,即代表该线程拥有了这个对象的锁,并且将对象的锁标志位转变为“00”
  4. 如果CAS操作失败,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行,否则就说明这个锁对象已经被其他线程抢占。
  5. 解锁过程:CAS操作把线程中保存的MW拷贝替换回对象头中。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

3 执行monitorenter后,对象发生什么变化?

  1. 对象的锁标志位转变为“00”
  2. 拥有对象锁的线程开辟了新空间,保存了对象的Mark Word信息
  3. 对象的Mark Word保存了线程的锁记录空间的地址拷贝

4 锁计数值保存在哪里
todo

monitorenter指令执行的过程


那么,有几个问题需明确:

  1. 什么叫对象锁
  2. 锁计数值保存在哪里,如何获取到?

还不懂?接下来从抽象-具象,一步步剖析对象锁。

4 深入理解对象锁monitor

4.1 抽象理解:对象锁monitor

  1. 每个对象都与对象锁关联【Each object is associated with a monitor--Oracle官方文档:java虚拟机指令集】

  2. 每个对象都自动含有单一的锁,称为对象锁【All objects automatically contain a single lock (also referred to as a monitor) --Thinking in Java】

  3. 对象与对象锁之间的关系有多种实现方式:【Oracle官方文档:java虚拟机指令集】

    1. 对象锁可以与对象同时分配和释放
    2. 线程试图获取对象锁时自动生成
  4. 线程进入synchronized块之前会尝试获得锁,线程在离开synchronized块时自动释放锁(无论正常退出,还是从块中抛出异常退出)。获得对象锁的唯一途径是: 进入对象锁保护的同步块或方法。

  5. 对象锁是一种互斥锁,当线程A尝试请求一个被线程B占有的锁时,A必须等待或者阻塞,直到B释放它。

  6. 对象锁与对象的状态之间没有关联,即使获得了对象锁也不能阻止其他线程访问这个对象,获得对象对象锁后,唯一的影响是阻止其他线程获得这把锁

4.2 具象理解:对象锁monitor

[以下内涉及hotspot源码,即是synchronized的的底层核心实现,也是Object的wait/notify/notifyAll方法的底层核心实现,初学者可暂时跳过这部分]

4.2.1 对象锁数据结构

源码层面,对象锁是由ObjectMonitor(HotSpot源码ObjectMonitor.hpp文件)实现的,数据结构及说明如下:

位置:openjdk\hotspot\src\share\vm\runtime\objectMonitor.hpp
实现:C/C++
代码:
ObjectMonitor() {
    _header;       // 被加锁的对象的对象头
    _object;       // 锁寄生的对象,锁不是平白出现的,而是寄托存储于对象中
    _owner;          // 占用当前锁的线程
    _count;        // 抢占该锁的线程数
    _recursions;   //重入次数
    _waiters;      // 调用wait方法后等待的线程数
    _WaitSet; // 调用wait方法后的线程,会被加入到_WaitSet
    _previous_owner_tid; // 上一个拥有锁的线程id
    OwnerIsThread ;   // 表明当前owner原来持有轻量级锁
    _cxq ;    // 竞争队列,所有请求锁的线程首先被放在这个竞争队列中
    _EntryList ;     // 处于等待锁block状态的线程,会被加入到该列表
    _succ ;          // Heir presumptive thread - used for futile wakeup throttling
    _Responsible ;
    _PromptDrain ;       // rqst to drain cxq into EntryList ASAP
    _Spinner ;           // 用来记录正在自旋的线程数
    _SpinFreq ;          // Spin 1-out-of-N attempts: success rate
    _SpinClock ;
    _SpinDuration ; //用来控制自旋的总次数
    _SpinState ;    // MCS/CLH list of spinners
    _WaitSetLock;        // 操作WaitSet链表的锁
    _QMix ;                       // Mixed prepend queue discipline
    ObjectMonitor * FreeNext ;        // Free list linkage
}

对象锁结构补充说明

  • cxq:竞争队列,所有请求锁的线程首先被放在这个竞争队列中 【jdk1.6是Contention List , jdk1.8变为_cxq,因此原作者图文中的Contention List等同于cxq】。
  • _EntryList:cxq中那些有资格成为候选资源的线程被移动到Entry List中。
  • _WaitSet:那些调用wait方法被阻塞的线程被放置在这里
  • _owner:初始时为NULL表示当前没有任何线程拥有该锁,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL,当前已经获取到所资源的线程被称为Owner。
  • _recursions:用来实现重入锁的计数

4.2.2 线程竞争对象锁过程

简单分析线程竞争对象锁时数据结构变化:

  1. 首先,每个等待锁的线程都会被封装成ObjectWaiter 【ObjectWaiter的源码暂不展开分析】
  2. _WaitSet和_EntryList:保存竞争锁的线程列表(ObjectWaiter列表);_owner:保存持有对象锁的线程
  3. 当多个线程同时访问一段同步代码时:
    1. 首先会进入 _EntryList 集合
    2. 当线程获取到锁后进入 _Owner 区域并把_owner设置为当前线程。同时计数器count加1,若线程调用 wait() 方法,将释放当前持有的锁,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放锁并复位变量的值,以便其他线程进入获取锁

如图所示:

5 Synchronized与Lock

synchronized的缺陷

  1. 在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。
  2. 挂起线程和恢复线程的操作都需要转入内核态中完成,上下文切换需要消耗很大性能。
  3. 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时
  4. 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活

6 使用Synchronized有哪些要注意的

  • 锁对象不能为空,因为锁的信息都保存在对象头里
  • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错
  • 在能选择的情况下,既不要用Lock也不要用synchronized关键字,用java.util.concurrent包中的各种各样的类,如果有必要,使用synchronized关键,因为代码量少,避免出错
  • synchronized实际上是非公平的,新来的线程有可能立即获得执行,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。

7 解惑

1 对象锁是什么

  1. 所有java对象的对象头的 mark word 有2bit空间存储对象锁的标志位,默认值为01(即未锁定)
  2. 以轻量级锁定为例:线程获取对象锁,如果成功,则在线程栈帧中开辟一块内存区域(锁空间lock record),保存mark word的拷贝,并把对象头mark word CAS更新为指向Lock Record的指针。
  3. 对象锁就是:线程栈帧中的新开辟的那块内存空间,它的完整数据结构是ObjectMonitor
  4. 因为所有对象都分配了2bit内存空间来存储锁标志位,因此可以说每个java对象都自动包含一个锁
    如图所示;

轻量级锁CAS操作之前堆栈与对象的状态:

轻量级锁CAS操作之后堆栈与对象的状态:

2 锁计数值保存在哪里,如何获取到?

保存在对象锁的数据结构:ObjectMonitor的_recursions字段中。

3 synchronized的显式/隐式同步

  1. synchronized修饰方法的代码块时为显式同步,并在同步代码块前后生成monitorenter和monitorexit指令
  2. synchronized修饰方法,它是隐式同步,由方法调用指令读取运行时常量池中方法的ACC_ SYNCHRONIZED 标志来隐式实现的,实现过程与monitorenter和monitorexit指令不同,但效果一致

4 synchronized与轻量级锁、重量级锁、偏向锁是什么关系

轻量级锁、重量级锁、偏向锁 这些概念都是JDK1.6引入的针对synchronized的锁优化措施


参考资料:
《深入理解java虚拟机》
《java虚拟机规范_java8版》
《java并发编程实践》
文章:(二)彻底理解Java并发编程之Synchronized关键字实现原理剖析
官方文档:Java虚拟机指令集

posted @ 2022-12-29 23:57  拿了桔子跑-范德依彪  阅读(430)  评论(0编辑  收藏  举报