1. 前言
众所周知,在单线程运行环境中,因为不存在资源竞争,所以不需要锁。但是,在多线程运行环境中,因为存在资源共享与竞争,为了合理分配资源以及公平地使用资源,所以需要锁。在计算机系统中,多线程需要多核处理器的支持,而每个核是以时间片的方式进行资源调度,一旦线程获取到时间片,则开始执行代码逻辑,线程没有获取时间片,则暂停执行代码逻辑。
Java支持同步锁(synchronization lock)机制以及可重入锁(re-entrant lock)机制,本章节主要描述同步锁的机制。
2. 同步锁定义
Java提供多种机制支持多线程之间的交互,包括同步锁机制、可重入锁机制以及内存可见性机制。其中最基本的是同步锁机制,同步锁机制使用监视器(monitors)实现。
每个Java对象都关联一个监视器(monitor),在多线程的运行环境中,当一个线程需要使用一个Java对象的同步锁机制的时候,都需要锁住(lock)这个对象对应的监视器,当不需要使用该对象的时候,都需要解锁(unlock)这个对象对应的监视器,同时,一次只能有一个线程允许锁住该对象对应的监视器,其他线程只能阻塞而等待监视器解锁。此外,一个线程能多次锁住同一监视器。
Java对象中使用同步锁机制的方式包括同步语句(synchronized statement)以及同步方法(synchronized method)。
2.1. 同步语句(synchronized statement)
Java语言提供的锁类型包括互相排斥锁类型以及互相共享锁类型,而同步锁是属于互相排斥锁类型,也称之为独占锁类型,当一个线程获取到同步锁(独占该锁,其他线程不能获取),则开始执行同步块,执行完毕解锁给其他线程使用。
同步语句也被称之为同步块,其使用的关键字是synchronized,语法形式是synchronized(表达式){同步块},其中表达式的计算结果是引用类型。代码示例如下所示:
以下步骤描述同步语句的执行逻辑:
-
首先计算(表达式)中的表达式,从计算结果中获取到代表同步锁的引用对象V
-
如果计算表达式的过程中发生异常,则结束执行当前同步块,如果锁引用对象V是空,则抛出空指针异常
-
运行线程锁住V的监视器(monitor),锁不住,则当前运行线程阻塞等待
-
运行线程执行{同步块}中的同步块
-
如果执行同步块过程中无异常发生,执行完毕则解锁V的监视器锁,结束同步块,如果执行同步块过程中发生异常,解锁V的监视器锁,结束同步块,抛出异常
此外,同一个Java对象中,在出现同步块的逻辑中才存在监视器锁的竞争,在该Java对象中的其他域或者其他非同步块的逻辑可以正常地被其他线程同时执行。
2.2. 同步方法(synchronized method)
同步方式使用关键字synchronized修饰一个类方法,当前一个线程执行该类的同步方法之前,需要锁住该方法对应的监视器(monitor),如果是执行静态方法,则该监视器是属于类的,如果是执行非静态方法,则该监视器是属于类实例的。
同步方法与同步块的执行逻辑相同,执行完毕或者执行中途发生异常,则需要解锁对象的监视器。
Test类的同步方法的代码示例如下所示,getNonStaticA、getNonStaticA_same使用相同的Test类实例的监视器,getStaticA、getStaticA_same使用相同的Test类的监视器:
以下代码所示,在多线程的并发环境中使用同一个Test类实例,如果调用getNonStaticA的次数与调用setNonStaticA的次数相同,则nonstatic_a的值等于第一次调用的值。
3. 字节码分析
本章节从编译后字节码的角度分析同步块的执行逻辑,以下示例是Test类的main方法内使用同步块的字节码:
下表详细描述Test类的main方法字节码所表示的意义:
字节码 | 描述 |
0: new | 新建的localLock对象 |
4: invokespecial | 初始化对象的基本初始化方法 |
7: astore_1 | 保存localLock对象到变量1 |
8: aload_1 | 入栈 |
11: monitorenter | 第一次进入监视器(获取监视器锁) |
14: astore_3 | 保存localLock对象到变量3 |
15: monitorenter | 第二次进入监视器(获取监视器锁) |
16: getstatic 19: ldc 21: invokevirtual | 执行同步块的业务逻辑 |
24: aload_3 | 监视器锁入栈 |
25: monitorexit | 解锁第二次进入监视器 |
32: aload_2 | 监视器锁入栈 |
33: monitorexit | 解锁第一次次进入监视器 |
Exception table: | 异常表,异常流程需要使用该表 |
Test类同步方法setNonStaticA的字节码如下所示,同步方法没有使用监视器的指令,只使用同步标识,JVM在执行字节码的时候,判断方法的同步标识,如果是同步方法则默认地执行监视器锁操作、解锁操作:
4. 原理分析
由以上字节码分析章节所述,同步块与同步方法的执行方式存在区别,但是两种同步锁方式都使用了相同的机制:监视器(monitor)。
4.1. 同步语句原理
同步块使用监视器涉及到两个字节码指令monitorenter、monitorexit,下面分别详细描述这两个指令。
4.1.1. monitorenter指令
monitorenter指令对应的操作数是引用对象,而每个对象对应一个监视器(monitor),当这个监视器拥有属主,则说明其已被调用线程锁定,调用线程使用monitorenter指令去获得引用对象监视器的拥有权的流程如下所述:
-
如果引用对象监视器的进入次数e=0,则调用线程t1进入监视器并设置e=1,设置成功后调用线程t1成为该监视器的拥有者
-
如果调用线程t1已经成为该监视器的拥有者,则再次进入监视器时,设置e=e+1
-
如果其他线程t2已经拥有该监视器,则调用线程t1阻塞直到e=0,然后t1尝试进入监视器
如果监视器对应的引用对象为空,则抛出空指针异常。
4.1.2. monitorexit指令
monitorexit指令对应的操作数是引用对象,与monitorenter指令对应的是同一个引用对象。执行monitorexit指令的调用线程必须是引用对象监视器的拥有者,调用线程进入监视器设置进入次数e=e-1,如果e=0则调用线程退出监视器,调用线程不再是监视器的拥有者,然后,其他阻塞线程可以调用monitorenter指令进入监视器成为该监视器的拥有者。
如果监视器对应的引用对象为空,则抛出空指针异常。
4.2. 同步方法原理
同步方法的字节码执行方式与同步块的不同,同步方法是在当方法被调用时由JVM根据方法(method_info)提供的信息判断方法是否是同步方法,如果是同步方法,则自动地对引用对象的监视器加锁,当方法的代码逻辑被执行完成,JVM在被调用方法返回时对引用对象的监视器解锁。
4.2.1. 类文件method_info
Java对象类文件的字节码对应的数据结构如下所示,其中method_info字段是存储类对象的方法信息:
其中method_info的数据结构如下所示,其中access_flags标识了方法的各种类型,该类型的长度是2个字节共计16位,使用二进制mask运算的方式标识16位中的每个一字节位的信息:
access_flags标识的类型如下表所示,其中ACC_SYNCHRONIZED位的标识是同步方法: