前言
这篇博客,南国就Java并发编程经常会用到的synchronized关键字做一个深入的剖析。如果读者对于Java并发的一些基础知识没有太多了解,欢迎查看我前段时间写过的Java面试07——并发知识点汇总及源码解析,在这篇博客中我对并发编程中的大部分知识有了一个基础的概括和总结,适合刚入行Java或者准备面试的人群进行复习。
这篇博客 ,结合网上其他大佬的技术文章 我主要就synchronized关键字进行详细的阐述。在此特别致敬以下博客的博主:
深入理解Java并发之synchronized实现原理
Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)
Java并发编程:Synchronized及其实现原理
Synchronized的基本使用
Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:(1)确保线程互斥的访问同步代码(2)保证共享变量的修改能够及时可见(3)有效解决重排序问题。从语法上讲,Synchronized总共有三种用法:
(1)修饰普通方法
(2)修饰静态方法
(3)修饰代码块
下面我们通过代码来讲解一下:
案例一
1.没有同步的情况
package 并发多线程;
/**
* synchronized 关键字对普通方法的修饰作用
* @author xjh 2019.05.04
* 可通过不同的结果来查看 区别
*/
public class SynchronizedTest {
public void method1(){ //加synchronized关键字 即对类中的方法进行同步 加锁
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2(){
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest test = new SynchronizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.method2();
}
}).start();
}
}
执行结果 如下:
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 2 end
Method 1 end
线程1和线程2同时进入执行状态,线程2执行速度比线程1快,所以线程2先执行完成,这个过程中线程1和线程2是同时执行的。
2.synchronized对普通方法进行互斥同步
这里也就是对上面的method1() 和method2()方法加上sybchronized关键字。
输出结果:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
可以很明显的看出,线程2需要等待线程1执行完成后才能开始执行method2()。
3.静态方法(类)同步
package 并发多线程;
/**
*synchronized对静态方法的同步本质上是对类的同步(静态方法本质上是属于类的方法,而不是对象上的方法)
* 所以即使test和test2属于不同的对象,但是它们都属于SynchronizedTest类的实例,
* 所以也只能顺序的执行method1和method2,不能并发执行。
* @author xjh 2019.05.04S
* 输出结果为:
*
*/
public class SynchronizedTest02 {
public static synchronized void method1(){
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public static synchronized void method2(){
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest02 test = new SynchronizedTest02();
final SynchronizedTest02 test2 = new SynchronizedTest02();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test2.method2();
}
}).start();
}
}
输出结果:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
我们知道 类中的静态方法属于类,而不再是只属于类中某个对象的单独方法。所以,对类中静态方法的同步实质上就是对类的同步,这里即使test和test2属于不同的对象,但是它们都属于SynchronizedTest类的实例,所以也只能顺序的执行method1和method2,不能并发执行。
4.代码块同步
package 并发多线程;
/**
* Synchronized 进行代码块同步
* @author xjh 2019.05.04
* 虽然线程1和线程2都进入了对应的方法开始执行,
* 但是线程2在进入同步块之前,需要等待线程1中同步块执行完成。
*/
public class SynchronizedTest03 {
public void method1(){
System.out.println("Method 1 start");
try {
synchronized (this) {
System.out.println("Method 1 execute");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2(){
System.out.println("Method 2 start");
try {
synchronized (this) {
System.out.println("Method 2 execute");
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
SynchronizedTest03 test=new SynchronizedTest03();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.method2();
}
}).start();
}
}
输出结果:
Method 1 start
Method 1 execute
Method 2 start
Method 2 execute
Method 1 end
Method 2 end
虽然线程1和线程2都进入了对应的方法开始执行,但是线程2在进入同步块之前,需要等待线程1中同步块执行完成。
案例二
1.synchronized修饰实例方法
public class AccountingSync implements Runnable{
//共享资源(临界资源)
static int i=0;
/**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
AccountingSync instance=new AccountingSync();
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果:2000000
分析:我们开启两个线程操作同一个共享资源即变量i,由于i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全。
此时我们应该注意到synchronized修饰的是实例方法increase,在这样的情况下,当前线程的锁便是实例对象instance,注意Java中的线程同步锁可以是任意对象。从代码执行结果来看确实是正确的,倘若我们没有使用synchronized关键字,其最终输出结果就很可能小于2000000,这便是synchronized关键字的作用。
这里我们还需要意识到,当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法,但是其他线程还是可以访问该实例对象的其他非synchronized方法。
当然如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下所示:
//我们把上面的代码略微做下修改,在main()实例化两个不同的对象
public static void main(String[] args) throws InterruptedException {
//AccountingSync instance=new AccountingSync();
Thread t1=new Thread(new AccountingSync());
Thread t2=new Thread(new AccountingSync());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
输出结果:1402768
注意这个结果是不唯一的,即每次执行都有可能不同 但都会小于2000。虽然我们使用synchronized修饰了increase方法,但却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。
解决这种困境的的方式是将synchronized作用于静态的increase方法,这样对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。代码如下所示:
public class AccountingSyncClass implements Runnable{
static int i=0;
/**
* 作用于静态方法,锁是当前class对象,也就是
* AccountingSyncClass类对应的class对象
*/
public static synchronized void increase(){
i++;
}
/**
* 非静态,访问时锁不一样不会发生互斥
*/
public synchronized void increase4Obj(){
i++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新实例
Thread t1=new Thread(new AccountingSyncClass());
//new新实例
Thread t2=new Thread(new AccountingSyncClass());
//启动线程
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
输出结果:2000000
由于synchronized关键字修饰的是静态increase方法,与修饰实例方法不同的是,其锁对象是当前类的class对象。注意代码中的increase4Obj方法是实例方法,其对象锁是当前实例对象,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量i)。
synchronized底层原理
Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块),还是隐式同步都是如此。
在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。
理解Java对象头与Monitor
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:
实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。
而对于顶部,则是Java头对象,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:
其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。
这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
首先解释一下几个核心组件的概念:
- Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
- Owner:当前已经获取到所资源的线程被称为Owner;
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程。
当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1。同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
synchronized同步代码块底层原理
我们回到前面案例一 synchronized代码块的代码:
.....
public void method1(){
System.out.println("Method 1 start");
try {
synchronized (this) {
System.out.println("Method 1 execute");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
.....
反编译后的结果:
从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。
1.monitorenter
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
2.monitorexit
执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
通过上面描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
synchronized同步方法底层原理
....
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
....
反编译后的结果:
从上图看出,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),而是其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。
我们注意到在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。
JVM 对 synchronized 的优化
锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,这里并不打算深入到每个锁的实现和转换过程更多地是阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》。
偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
1、偏向锁获取过程:
(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。
(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。
(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
(5)执行同步代码。
2、偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量级锁
“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的。但是,首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。在解释轻量级锁的执行过程之前,先明白一点,轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
1、轻量级锁的加锁过程
(1)在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如下图所示。
(2)拷贝对象头中的Mark Word复制到锁记录中。
(3)拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
(4)如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如下图所示。
(5)如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
2、轻量级锁的解锁过程:
(1)通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
(2)如果替换成功,整个同步过程就完成了。
(3)如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
3、重量级锁、轻量级锁和偏向锁之间转换
其他优化
1、适应性自旋(Adaptive Spinning):从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。问题在于,自旋是需要消耗CPU的,如果一直获取不到锁的话,那该线程就一直处在自旋状态,白白浪费CPU资源。解决这个问题最简单的办法就是指定自旋的次数,例如让其循环10次,如果还没获取到锁就进入阻塞状态。但是JDK采用了更聪明的方式——适应性自旋,简单来说就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
2、锁粗化(Lock Coarsening):锁粗化的概念应该比较好理解,就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成一个范围更大的锁。举个例子:
package com.paddx.test.string;
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
}
}
这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。
3、锁消除(Lock Elimination):锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁。看下面这段程序:
package com.paddx.test.concurrent;
public class SynchronizedTest02 {
public static void main(String[] args) {
SynchronizedTest02 test02 = new SynchronizedTest02();
//启动预热
for (int i = 0; i < 10000; i++) {
i++;
}
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
test02.append("abc", "def");
}
System.out.println("Time=" + (System.currentTimeMillis() - start));
}
public void append(String str1, String str2) {
StringBuffer sb = new StringBuffer();
sb.append(str1).append(str2);
}
}
虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中逃逸出去,所以其实这过程是线程安全的,可以将锁消除。
JDk中采用轻量级锁和偏向锁等对Synchronized的优化,但是这两种锁也不是完全没缺点的,比如竞争比较激烈的时候,不但无法提升效率,反而会降低效率,因为多了一个锁升级的过程,这个时候就需要通过-XX:-UseBiasedLocking来禁用偏向锁。下面是这几种锁的对比:
synchronized的一些重要特性
synchronized可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:
package 并发多线程;
/**
* 这个demo主要讲syncjroined的可重入性
* @author xjh 2019.12.01
*/
public class AccountingSync2 implements Runnable{
static int i=0,j=0;
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
for (int k=0;k<1000;k++){
synchronized (this){ //在代码块上加锁
i+=2;
increase(); //synchroinzed的可重入性
}
}
System.out.println(Thread.currentThread().getName()+" i= "+i+"; j= "+j);
}
public synchronized void increase(){
j++;
}
static AccountingSync2 accountingSync2=new AccountingSync2();
public static void main(String[] args) {
Thread t1=new Thread(accountingSync2);
Thread t2=new Thread(accountingSync2);
t1.start(); t2.start(); //同一个对象锁 开启两个线程
try {
t1.sleep(3000); //sleep()睡眠 中断cpu执行,但不会释放锁
// t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// try {
// t2.sleep(500);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
}
正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。
线程中断与synchronized
首先,我们讲讲Interrupt()结束线程:
中断
Java中提供了以下3个有关线程中断的方法:
//中断线程(实例方法)
public void Thread.interrupt();
//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();
//判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();
当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()方式中断该线程,注意此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态),如下代码将演示该过程:
package 并发多线程;
import java.util.concurrent.TimeUnit;
/**
* 线程中断
* @author xjh 2019.05.04
*/
public class InterruputSleepThread {
public static void main(String[] args) throws InterruptedException{
Thread t1 = new Thread() {
@Override
public void run() {
//while在try中,通过异常中断就可以退出run循环
try {
while (true) {
System.out.println("正常运行中1...");
//当前线程处于阻塞状态,异常必须捕捉处理,无法往外抛出
TimeUnit.SECONDS.sleep(2);
System.out.println("正常运行中2...");
}
} catch (InterruptedException e) {
System.out.println("Interruted When Sleep");
boolean interrupt = this.isInterrupted();
//中断状态被复位
System.out.println("interrupt:"+interrupt);
}
}
};
t1.start(); //首次start()启动线程,此时线程处于就绪状态,并没有运行
TimeUnit.SECONDS.sleep(2); //
//中断处于阻塞状态的线程
t1.interrupt();
}
}
/**
* 输出结果:
正常运行中1...
正常运行中2...
正常运行中1...
Interruted When Sleep
interrupt:false
*/
如上述代码所示,我们创建一个线程,并在线程中调用了sleep方法从而使用线程进入阻塞状态,启动线程后,调用线程实例对象的interrupt方法中断阻塞异常,并抛出InterruptedException异常,此时中断状态也将被复位。这里有些人可能会诧异,为什么不用Thread.sleep(2000);而是用TimeUnit.SECONDS.sleep(2);其实原因很简单,前者使用时并没有明确的单位说明,而后者非常明确表达秒的单位,事实上后者的内部实现最终还是调用了Thread.sleep(2000);,但为了编写的代码语义更清晰,建议使用TimeUnit.SECONDS.sleep(2);的方式,注意TimeUnit是个枚举类型。
除了阻塞中断的情景,我们还可能会遇到处于运行期且非阻塞的状态的线程,这种情况下,直接调用Thread.interrupt()中断线程是不会得到任响应的,如下代码,将无法中断非阻塞状态下的线程:
public class InterruputThread {
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run(){
while(true){
System.out.println("未被中断");
}
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
/**
* 输出结果(无限执行):
未被中断
未被中断
未被中断
......
*/
}
}
虽然我们调用了interrupt方法,但线程t1并未被中断,因为处于非阻塞状态的线程需要我们手动进行中断检测并结束程序,改进后代码如下:
public class InterruputThread {
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(){
@Override
public void run(){
while(true){
//判断当前线程是否被中断
if (this.isInterrupted()){
System.out.println("线程中断");
break;
}
}
System.out.println("已跳出循环,线程中断!");
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
/**
* 输出结果:
线程中断
已跳出循环,线程中断!
*/
}
}
我们在代码中使用了实例方法isInterrupted判断线程是否已被中断,如果被中断将跳出循环以此结束线程,注意非阻塞状态调用interrupt()并不会导致中断状态重置。综合所述,可以简单总结一下中断两种情况,一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位,另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。有时我们在编码时可能需要兼顾以上两种情况,那么就可以如下编写:
public void run(){
try {
//判断当前线程是否已中断,注意interrupted方法是静态的,执行后会对中断状态进行复位
while (!Thread.interrupted()) {
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
}
}
中断与synchronized
事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。演示代码如下:
public class SynchronizedBlocked implements Runnable{
public synchronized void f() {
System.out.println("Trying to call f()");
while(true) // Never releases lock
Thread.yield();
}
/**
* 在构造器中创建新线程并启动获取对象锁
*/
public SynchronizedBlocked() {
//该线程已持有当前实例锁
new Thread() {
public void run() {
f(); // Lock acquired by this thread
}
}.start();
}
public void run() {
//中断判断
while (true) {
if (Thread.interrupted()) {
System.out.println("中断线程!!");
break;
} else {
f();
}
}
}
public static void main(String[] args) throws InterruptedException {
SynchronizedBlocked sync = new SynchronizedBlocked();
Thread t = new Thread(sync);
//启动后调用f()方法,无法获取当前实例锁处于等待状态
t.start();
TimeUnit.SECONDS.sleep(1);
//中断线程,无法生效
t.interrupt();
}
}
我们在SynchronizedBlocked构造函数中创建一个新线程并启动获取调用f()获取到当前实例锁,由于SynchronizedBlocked自身也是线程,启动后在其run方法中也调用了f(),但由于对象锁被其他线程占用,导致t线程只能等到锁,此时我们调用了t.interrupt();但并不能中断线程。
等待唤醒与synchronized
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
synchronized (obj) {
obj.wait();
obj.notify();
obj.notifyAll();
}
需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。