synchronized是基于JVM中的Monitor锁实现的,Java1.5之前的synchronized锁性能较低,但是从Java1.6开始,对synchronized锁进行了大量的优化,引入可锁粗话、锁消除、偏向锁、轻量级锁、适应性自旋等技术来提升synchronized的性能。
当synchronized修饰方法时,当前方法会比普通方法在常量池中多一个ACC_SYNCHRONIZED标识符,synchronized修饰方法的核心原理如下图:
JVM在执行程序时,会根据这个ACC_SYNCHRONIZED标识符完成方法的同步。如果调用了被synchronized修饰的方法,则调用的指令会检查方法是否设置了ACC_SYNCHRONIZED标识符。
如果方法设置了ACC_SYNCHRONIZED标识符,则当前线程先获取monitor对象。同一时刻,只会有一个线程获取monitor对象成功,进入方法体执行方法逻辑。在当前线程释放monitor对象前,其它线程无法获取同一个monitor对象,从而保证了同一时刻只有一个线程进入被synchronized修饰的方法中执行方法体的逻辑。
当被加了synchronized的资源在执行过程中出现异常时,锁也会被释放。因此,在并发程序中一定要将异常及时处理,否则会影响并发的逻辑。
package com.nezha.thread;
public class SynchronizedTest {
public void test(){
synchronized(this){
System.out.println("Java知识,尽在哪吒");
}
}
}
javap -c SynchronizedTest.class
进行反编译:通过反编译,当synchronized修饰代码块时,会在编译出的字节码中插入monitorenter指令和monitorexit指令。
每个线程都有一个监视器锁monitor,当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时首先会尝试获取monitor的所有权。
线程执行monitorexit时,monitor计数会-1,如果-1后monitor的计数为0,则当前线程退出此monitor。其它被阻塞的线程尝试获取当前monitor的所有权。
大部分情况下,被添加synchronized锁的代码不会存在多线程竞争的情况,但是会出现同一个线程多次获取同一个synchronized锁的现象,这样很浪费性能,此时偏向锁应运而生。
如果在同一时刻有且仅有一个线程执行了synchronized修饰的方法,则执行方法的线程不存在与其它线程竞争锁的情况,此时,锁就会变为偏向锁。
当锁进入偏向状态时,对象头中的Mark Word的结构就会进入偏向结构。此时偏向锁标记为1,锁标志位为01,并将当前线程的ID记录在Mark Word中。当前线程如果再次进入此方法,要先检查对象头中的Mark Word中是否存储了自己的线程ID。
撤销偏向锁的过程:
synchronized是JVM中提供的内置锁,使用内置锁无法很好地完成一些特定场景下的功能。例如,内置锁不支持响应中断、不支持超时、不支持以非阻塞的方式获取锁。而lock锁是在JDK层面实现的一种比内置锁更灵活的锁,它能弥补synchronized内置锁的不足,他们都通过Java提供的接口来完成加锁和解锁操作。
JDK听过的Lock锁是通过Java提供的接口来手动加锁、解锁的,所以lock是一种显示锁。JDK提供的显示锁位于java.util.concurrent包下,也叫JUC显示锁。
(1)void lock();
阻塞模式抢占锁的方法。如果当前线程抢占锁成功,则继续向下执行程序的业务逻辑,否则,当前线程会阻塞,直到其它抢占到锁的线程释放锁后再继续抢占锁。
(2)void lockInterruptibly() throws InterruptedException;
可中断模式抢占锁的方法。当前线程在调用lockInterruptibly()方法抢占锁的过程中,能够响应中断信号,从而能够中断当前线程。
(3)boolean tryLock();
非阻塞模式下抢占锁的方法。当前线程调用tryLock()方法抢占锁时,线程不会阻塞,而会立即返回抢占锁的结果。
(4)boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
在tryLock()的基础上,加上限制抢占锁的时间限制。
(5)void unlock();
释放锁。
(6)Condition newCondition();
创建与当前线程绑定的Condition条件,主要用于线程间以“等待 - 通知”的方式进行通信。
所以,Lock锁支持响应中断、超时和以非阻塞的方式获取锁,全面弥补了JVM中synchronized内置锁的不足。
公平锁,顾名思义,就是争抢锁的时候,大家都是公平的。
每个线程抢占锁的时候,都会检索锁维护的等待队列,如果等待队列为空,或者当前线程是等待队列的第一个线程,则当前线程获取到锁,否则,当前线程加入到等待队列的尾部,然后等待队列中的线程会按先进先出的规则按顺序尝试获取资源。
非公平锁的核心就是抢占锁的所有线程是不公平的,在多线程并发环境中,每个线程在抢占锁的过程中都会先直接尝试抢占锁,如果抢占成功,就继续执行程序的业务逻辑,如果抢占失败,就会进入等待队列中排队。
公平锁和非公平锁的区别是,非公平锁在队列的处理上比公平锁多了一个插队的过程,,如果插队时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。
非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU 不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
JDK中提供了StampedLock类,StampedLock在读取共享变量的过程中,允许后面的一个线程获取写锁,并对共享变量进行写操作,它使用乐观读避免数据不一致,在读过写少的高并发环境下,是比ReadWriteLock更快的锁。
StampedLock支持写锁、读锁、乐观锁。
StampedLock与ReadWriteLock的不同之处在于,ReadWriteLock在获取读锁或写锁后,会返回一个Long类型的变量,之后再释放锁时,需要传入这个Long类型的变量。
在ReadWriteLock读取共享变量时,所有对共享变量的写操作都会被阻塞;而StampedLock提供的乐观读在多个线程读取共享变量时,允许一个线程对共享变量进行写操作。
StampedLock锁内部维护了一个线程等待队列,所有获取锁失败的线程都会进入这个等待队列,队列中的每个节点都代表一个线程,同时会在节点中保存一个标志位locked,用于表示当前线程是否获取到锁,true表示获取到锁,false表示未获取到锁。
当某个线程尝试获取锁时,会先获取等待队列尾部的线程作为当前线程的前驱节点,并且判断前驱节点是否已经成功释放锁,如果已经释放锁,则当前线程获取到锁并继续执行。如果前驱节点未释放锁,则当前线程自旋等待。
当某个线程释放锁时,会先将自身节点的locked标记设置为false,队列中后继节点中的线程通过自旋就能检测到当前线程已经释放锁,从而可以获取到锁并继续执行业务逻辑。
加锁使得原本能够并行执行的操作变得串行化,串行操作会降低程序的性能,CPU对于线程的上下文切换也会降低系统的性能。下面总结一下锁优化的相关方法。
将一些不会引起线程安全问题的代码,移出同步代码块,尤其是耗时的IO操作,或者可能引起阻塞的方法,这样能提高程序执行的速度。
减小锁的粒度就是缩小锁定的对象,比如将一个大对象拆分成多个小对象,对这些小对象进行加锁,能够提高程序的并行度,提高程序执行的速度。
锁分离最典型的技术就是读写锁,ReadWriteLock分为写锁和读锁,其中读读不互斥,读写互斥,写写互斥,这样既保证了线程安全,又提高了性能。
进一步缩小锁的粒度,对一个独立对象的锁进行分解的现象叫做锁分段。锁分段最典型的例子就是ConcurrentHashMap。
ConcurrentHashMap将数据按照不同的数据段进行存储,每个数据段分配一把锁,当某个数据段占有某个数据段的锁访问数据时,其它数据段的锁也能被其它线程抢占到,提高程序的并行度,提高程序性能。
如果同一个线程不停的请求、同步、释放同一把锁,则会降低程序的执行性能,此时可以扩大锁的范围,即进行锁粗化处理。
Java高并发编程实战1,那些年学过的锁
Java高并发编程实战2,原子性、可见性、有序性,傻傻分不清
Java高并发编程实战3,Java内存模型与Java对象结构
Java学习路线总结,搬砖工逆袭Java架构师
10万字208道Java经典面试题总结(附答案)
SQL性能优化的21个小技巧
Java基础教程系列
Spring Boot 进阶实战