1. synchronized介绍
1.1 synchronized作用
synchronized是一个用于同步的关键字。如果不进行同步可能会导致数据不安全,那么什么情况下会数据不安全呢,要满足两个条件:一是数据共享(临界资源),二是多线程同时访问并改变该数据。
synchronized锁的3种使用形式(使用场景):
- synchronized修饰普通同步方法:锁对象是当前实例对象;
- synchronized修饰静态同步方法:锁对象是当前的类Class对象;
- synchronized修饰同步代码块:锁对象是synchronized后面括号里配置的对象,这个对象可以是某个对象(xlock),也可以是某个类(Xlock.class)。
1.2 synchronized在JVM中的实现原理
1.2.1 从Java对象的内存布局说起
在Hotspot虚拟机中,对象在内存中的布局可以分为三块区域:对象头、实例数据和对齐填充。
- 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
- 实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
- 对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。因为 Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
我们知道,synchronized不论是修饰方法还是代码块,都是通过持有修饰对象的锁来实现同步,那么对象的锁是存在哪里的呢,是存在锁对象的对象头的MarkWord中的。
在32位的虚拟机中,对象头中的MarkWord如下:
在64位的虚拟机中,对象头中的MarkWord如下:
这里简单说下32位和64位系统区别: 32位系统CPU一次可处理32位数据,即一次处理4个字节。64位系统CPU一次可处理64位数据,即一次处理8个字节。由32位系统过渡到64位系统,CPU处理数据能力提升了一倍。 在寻址能力上,内存中一个地址占用8bit,即一个字节,32位cpu含有32根地址线,寻址能力为2的32次方个字节,相当于4G内存(所以,如果我们装32位系统,安装8G内存实际上是没有用的)。而64位cpu理论上寻址能力为2的64次方个字节,但目前硬件还达不到这个水准,当然我们用不了这么大的内存。64位系统下运行64位软件比32位系统运行32位软件要快,但是,64位系统运行32位软件跟32位系统运行32位软件速度应该是一样的。也就是说,64位CPU有更大的寻址能力。
1.2.2 synchronized在JVM中的实现原理
重量级锁对应的锁标志位是10,存储了指向重量级监视器锁的指针,在Hotspot中,对象的监视器(monitor)锁对象由ObjectMonitor对象实现(C++),其跟同步相关的数据结构如下:
ObjectMonitor() {
_count = 0; //用来记录该对象被线程获取锁的次数
_waiters = 0;
_recursions = 0; //锁的重入次数
_owner = NULL; //指向持有ObjectMonitor对象的线程
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
}
对于一个synchronized修饰的方法(或代码块)来说:
- 当多个线程同时访问该方法,那么这些线程会先被放进_EntryList队列,此时线程处于blocking状态
- 当一个线程获取到了实例对象的监视器(monitor)锁,那么就可以进入running状态,执行方法,此时,ObjectMonitor对象的_owner指向当前线程,_count加1表示当前对象锁被一个线程获取
- 当running状态的线程调用wait()方法,那么当前线程释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1,同时线程进入_WaitSet队列,直到有线程调用notify()方法唤醒该线程,则该线程重新获取monitor对象进入_Owner区
- 如果当前线程执行完毕,那么也释放monitor对象,进入waiting状态,ObjectMonitor对象的_owner变为null,_count减1
1.2.3 Synchronized修饰的代码块/方法如何获取monitor对象的
在JVM规范里可以看到,不管是方法同步还是代码块同步都是基于进入和退出monitor对象来实现,然而二者在具体实现上又存在很大的区别。 (1)Synchronized修饰代码块: Synchronized代码块同步在需要同步的代码块开始的位置插入monitorentry指令,在同步结束的位置或者异常出现的位置插入monitorexit指令;JVM要保证monitorentry和monitorexit都是成对出现的,任何对象都有一个monitor与之对应,当这个对象的monitor被持有以后,它将处于锁定状态。 同步方法块在进入代码块时插入了monitorentry语句,在退出代码块时插入了monitorexit语句,为了保证不论是正常执行完毕还是异常跳出代码块都能执行monitorexit语句,因此一般会出现两句monitorexit语句。 (2)Synchronized修饰方法: Synchronized方法同步不再是通过插入monitorentry和monitorexit指令实现,而是由方法调用指令来读取运行时常量池中的ACC_SYNCHRONIZED标志隐式实现的,如果方法表结构(method_info Structure)中的ACC_SYNCHRONIZED标志被设置,那么线程在执行方法前会先去获取对象的monitor对象,如果获取成功则执行方法代码,执行完毕后释放monitor对象,如果monitor对象已经被其它线程获取,那么当前线程被阻塞。
1.3 synchronized锁优化
1.3.1 锁的四种状态及锁升级
锁的四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)。均可由低级别锁升级成高级别锁状态,但锁降级只发生在偏向锁降级成无锁状态。 (1)偏向锁: 偏向锁的引入:经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。
偏向锁的升级:当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
偏向锁的取消:偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果想取消延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0; 如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置。 (2)轻量级锁 轻量级锁的引入:轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。
轻量级锁的升级:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
(3) 几种锁状态间的比较
1.3.2 锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。 锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。 Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。
1.3.3 锁粗化
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 但是加锁解锁也需要消耗资源,如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。 锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。
1.4 用synchronized实现两个线程交替打印1-10
wait():一旦执行该方法,当前线程就会堵塞状态,并释放同步监视器(锁) notify():一旦执行该方法,就会唤醒被wait的一个线程,如果是多个,则唤醒优先级高的线程 notifyAll():一旦执行该方法,就会唤醒所有被wait的线程 这三个方法必须使用在同步代码块或者同步方法中,用于线程间的通信,这也解释了为什么这三个方法是Object类的方法而sleep()却是Thread类的方法,因为两者发挥的功能本来就不一样。
public class ThreadTest {
private static Object lock = new Object();
private static int i = 1;
public static void main(String[] args) {
doSynchronized();
}
private static void doSynchronized() {
final int total = 10;
Thread thread1 = new Thread(() -> {
while (i <= total) {
synchronized(lock) {
if (i % 2 == 1) {
System.out.print(Thread.currentThread().getName() + " i=" + i++);
lock.notify();
System.out.println(" 奇数打印完毕,此时并没有释放锁,而是唤醒线程2准备抢锁");
} else {
try {
System.out.println("线程1释放锁,等待唤醒");
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
});
Thread thread2 = new Thread(() -> {
while (i <= total) {
synchronized (lock) {
if (i % 2 == 0) {
System.out.print(Thread.currentThread().getName() + " i=" + i++);
lock.notify();
System.out.println(" 偶数锁打印完毕,此时并没有释放锁,而是唤醒线程1准备抢锁");
} else {
try {
System.out.println("线程2释放锁,等待唤醒");
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
});
thread1.start();
thread2.start();
}
}
结果为:
1.5 用synchronized实现一个简单的死锁
public class DeadLockTest {
public static void main(String[] args) {
Thread thread1 = new Thread(new DeadLock(true));
Thread thread2 = new Thread(new DeadLock(false));
thread1.start();
thread2.start();
}
}
class DeadLock implements Runnable {
boolean flag;
static Object o1 = new Object();
static Object o2 = new Object();
DeadLock() {}
DeadLock(boolean flag) {
this.flag = flag;
}
@Override
public void run() {
if (this.flag) {
synchronized (o1) {
try {
Thread.sleep(500); // 线程1获取o1对象锁后等待0.5秒,让线程2去获取o2对象锁。
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("1 ok");
}
}
} else {
synchronized (o2) {
try {
Thread.sleep(500); // 线程2获取o2对象锁后等待0.5秒,让线程1去获取o1对象锁。
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) {
System.out.println("2 ok");
}
}
}
}
}
运行程序后,程序无法正常终止。
2. synchronized与ReentrantLock的区别?
ReentrantLock是可重入的独占锁,同时只能有一个线程可以获取该锁,其他获取该锁的线程会被阻塞而被放入该锁的AQS阻塞队列里面。ReentrantLock主要利用CAS+AQS队列来实现。synchronized与ReentrantLock的区别如下。 (1)底层实现上区别 synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),对象只有在同步块或同步方法中才能调用wait/notify方法,ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。 synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁,ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。 (2)是否可手动释放 synchronized不需要用户去手动释放锁,synchronized代码执行完后系统会自动让线程释放对锁的占用;ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。 (3)是否可中断 synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成;ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。 (4)是否公平锁 synchronized为非公平锁,ReentrantLock则默认为非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。 (5)锁是否可绑定条件Condition synchronized不能绑定;ReentrantLock通过绑定Condition结合await()/singal()方法实现线程的精确唤醒,而不是像synchronized通过Object类的wait()/notify()/notifyAll()方法要么随机唤醒一个线程要么唤醒全部线程。 (6)锁的对象不同 synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。