Java采用synchronized关键字、以互斥同步的方式的解决线程安全问题,那么什么是线程安全呢?这里引用《Java并发编程实战》作者Brian Goetz给出的定义:
“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”—— Brian Goetz
一、synchronized的使用
(一)使用方法
// 修饰实例方法
public synchronized void test1(){
}
// 修饰代码块
public void test2(){
synchronized(new Test()){
}
}
// 修饰静态方法
public static synchronized void test3(){
}
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。
因此,当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁。
2、synchronized作用于静态方法
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);
}
}
由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态 成员的并发操作。当synchronized作用于静态方法时,其锁就是当前类的class对象锁。
如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的class对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
3、synchronized作用于代码块
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
@Override
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为instance
synchronized(instance){
for(int j=0;j<1000000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
从代码看出,将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁,如下代码:
//this,当前实例对象锁
synchronized(this){
for(int j=0;j<1000000;j++){
i++;
}
}
//class对象锁
synchronized(AccountingSync.class){
for(int j=0;j<1000000;j++){
i++;
}
}
总结:
无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁。
(二)字节码分析
public synchronized void test1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // here
public void test2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class com/easy/helloworld/Test
3: dup
4: invokespecial #3 // Method "<init>":()V
7: dup
8: astore_1
9: monitorenter // here
10: aload_1
11: monitorexit // here
12: goto 20
15: astore_2
16: aload_1
17: monitorexit // here
18: aload_2
19: athrow
20: return
public static synchronized void test3();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED // here
可以观察到:
- 修饰实例方法:隐式调用moniterenter、moniterexit
- 修饰代码块:通过moniterenter、moniterexit 关联到到一个monitor对象,进入时设置Owner为当前线程,计数+1、退出-1。除了正常出口的 monitorexit,还在异常处理代码里插入了 monitorexit。
- 修饰静态方法:隐式调用moniterenter、moniterexit
1、Java对象头
moniterenter和moniterexit这两个jvm指令,主要是基于 Mark Word
和Object monitor
来实现的。其中Mark Word在对象头中,对象头的具体结构如下:
2、Monitor
Monitor被翻译成监视器或管程。每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word就被设置为指向Monitor对象的指针。
(这部分后续补充)
二、轻量级锁
引入轻量级锁的目的:在多线程交替执行同步块的情况下,尽量避免重量级锁使用的操作系统互斥量带来的开销,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的,也就是没有竞争,那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是synchronized。
进入轻量级锁的流程:
- 获取对象的Mark Word,判断标识位是否为001,无锁状态且不可偏向。
- 如果标识位是001,创建锁记录(Lock Record)对象。每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。
- 让锁记录中的Object reference指向锁对象,并尝试用CAS替换Object的Mark Word,将Mark Word的值存入锁记录。
- 如果CAS替换成功,对象头中存储了锁记录的机制和状态00,表示由该线程给对象加轻量级锁。
- 如果是锁重入情况,则再添加一条锁记录,并设置Mark Word=null。
- 当退出synchronized代码块时(解锁),如果锁记录取值不为null,使用CAS将Mark Word的值恢复给对象头,成功则解锁成功,反之说明轻量级锁进行了锁膨胀或者已经升级为重量级锁,需要进入重量级锁解锁流程。
三、锁膨胀
如果在尝试加轻量级锁的过程中,CAS操作无法成功,一种情况就是由其它线程为次对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
- 当thread1进行轻量级加锁时,thread0已经对该对象加了轻量级锁
- 这时thread1加轻量级锁失败,进入锁膨胀流程:
- 为Object对象申请Monitor锁,让Object指向重量级锁的地址,
- 然后自己进入Monitor的EntryList中阻塞。
- 当thread0退出同步块解锁时,使用CAS将Mark Word的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中阻塞的线程。
四、自旋优化
重量级锁竞争的时候,还可以使用自旋在进行优化,如果当前线程自旋成功,也就是这时候持锁线程已经退出了同步块,释放了锁,这时当前线程就可以避免阻塞。
五、偏向锁
经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。
但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
六、撤销
(一)调用对象的hashCode
调用了对象的hashCode,但偏向锁的对象MarkWord中存储的是线程id,如果调用hashCode会导致偏向锁被撤销,因为偏向锁对象MarkWord里没有hashCode。
- 轻量级锁会在锁记录中记录hashCode
- 重量级锁会在Monitor中记录hashCode
(二)其它线程使用对象
有偏向锁会使用偏向锁,如果有错开的其它线程使用这个对象,就撤销偏向锁,变成轻量级锁。如果这时有竞争发生(并没有错开),锁就会膨胀为重量级锁。
七、批量重偏向
如果对象虽然被多个线程访问,但没有竞争,这时偏向了thread1的对象仍有机会重新偏向thread2,重偏向会重置对象的threadID。当撤销偏向锁的次数达到超过阈值20次之后,jvm会认为偏向错误,于是给这些对象加锁时重新偏向至加锁线程。
假设有30个对象,这30个对象都偏向threadA,后来由于threadB来使用这些对象,在使用前20个对象的时候,会先撤销它们的偏向,再改为轻量级锁。循环20次后,jvm批量修改后面的对象,让它们偏向threadB。
八、批量撤销
当撤销偏向锁阈值超过40次后,jvm会觉得这个类竞争过于激烈,根本不该偏向,于是整个类的所有对象都变为不可偏向,新建的对象也都是不可偏向的。
九、中断
(一)Java线程中断
在Java中与线程中断的方法如下:
//中断线程(实例方法)
public void Thread.interrupt();
//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();
//判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();
中断的两种情况可以简单概括为:
- 线程处于阻塞状态或者试图执行一个阻塞操作时,调用中断方法,打断阻塞状态
- 线程处于运行状态时,调用中断方法,使线程中断
public class InterruputSleepThread3 {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread() {
@Override
public void run() {
//while在try中,通过异常中断就可以退出run循环
try {
while (true) {
//当前线程处于阻塞状态,异常必须捕捉处理,无法往外抛出
TimeUnit.SECONDS.sleep(2);
}
} catch (InterruptedException e) {
System.out.println("Interruted When Sleep");
boolean interrupt = this.isInterrupted();
//中断状态被复位
System.out.println("interrupt:"+interrupt);
}
}
};
t1.start();
TimeUnit.SECONDS.sleep(2);
//中断处于阻塞状态的线程
t1.interrupt();
/**
* 输出结果:
Interruted When Sleep
interrupt:false
*/
}
}
以上代码通过调用sleep方法使线程t1阻塞,然后调用interrupt方法中断了t1的阻塞,t1抛出异常,最后通过isInterruted=false可以得出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();
/**
* 输出结果:
线程中断
已跳出循环,线程中断!
*/
}
}
以上代码是在t1线程运行过程中,通过调用interrupt方法改变了isInterrupted的值,然后手动中断线程。
(二)synchronized与中断
事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。
十、等待唤醒机制与synchronized
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。
需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。
参考
黑马程序员视频-JUC并发编程教程
https://blog.youkuaiyun.com/javazejian/article/details/72828483