锁的分类
为了解决多线程并发环境下的线程安全问题,Java提出了锁的机制。与我们之前学习MySQL解决并发下事务的问题而提出不同的锁一样。
锁的机制解决了线程安全问题,为了降低锁对性能问题的影响
,针对不同的场景,Java对锁添加了不同的特性
,满足不同的需求,将锁进行优化。
针对不同的特性与场景对锁进行了不同的分类:
阻塞与非阻塞
阻塞与非阻塞的区别在于当前线程在获取锁失败时是否让出CPU时间,挂起线程
。
在阻塞情况下,当前线程需要在获取失败时让出CPU时间,当占用锁的线程执行完任务后,释放锁时,再唤醒当前线程重新获取锁。
但是挂起和唤醒线程,都需要CPU在用户态和内核态之间切换
,这些操作都会消耗系统资源,在同步块逻辑处理简单的情况下,切换线程带来的系统消耗会大于节约的,得不偿失。
因此Java引入了自旋锁,来实现非阻塞的同步
。
所谓自旋就是在获取锁失败
时,不放弃CPU时间
,当前线程进行空循环(自旋
),自旋结束如果等待的锁被释放就可以直接获取锁,从而避免线程切换
。
自旋锁的实现原理是
CAS( Compare And Swap
),在Java的原子类AtomicInteger中就使用了Unsafe的CAS操作:do-whlie循环中的,如果set数值失败就会一直循环执行,直到成功。
自旋锁避免了线程切换开销,但是如果长时间占用CPU则会浪费CPU资源,因此可以通过
-XX:PreBlockSpin
来控制自旋的次数。在JDK 1.6还引入了
自适应自旋锁
,自旋时间可以根据上次在同一个锁上的自旋时间
和锁的拥有者的状态
来决定。
乐观与悲观
乐观与悲观是其实指的是一种思想。与MySQL事务详解(三):脏写与幻读的绝路—锁一文中提到的乐观与悲观一样。
乐观
假定当前线程修改数据时,没有其他线程与其发生竞争,不主动加锁
,更新数据时如果被其他线程更新,
与预期不一致,则可以给出不同的补偿操作保持数据的一致。上述提高的CAS算法就是乐观锁的一种实现
。
悲观
则与乐观相反,操作时会主动加锁
,保持自己对资源的独享,避免其他线程的修改。Synchronized、Lock都是悲观锁
。
共享与排他
共享与排他同样是一种思想,顾名思义,两者的区别在于当前的锁是否可以同时被多个线程所持有
。
Synchronized、Lock只能被一个线程所持有,锁住的资源只能被当前持有锁的线程所访问,所以是排他锁。
但是排他锁带来一个问题,针对读多写少的业务场景,每次读去数据都要独占资源,这会降低读的性能
。
为此引入了共享锁,可以被多个线程锁持有,那么共享的资源就可以同时被多个线程所访问,注意的是共享锁只能用于读数据,且加了共享锁就不能再加排他锁
。
Java中的ReentrantReadWriteLock里面的ReadLock就是共享锁
,通过读写分离,ReentrantReadWriteLock提高的读操作的并发性,从而提高的性能。
可重入与不可重入
可重入锁,又称为递归锁
,当一个线程在外层方法
中获取锁之后,内层方法
中再次获取同一个锁时,会自动获取
而不会因为之前的锁没有释放而阻塞。
public class TestSynchronized {
public synchronized void levelOne(){
System.out.println("level one");
levelTwo();
}
public synchronized void levelTwo(){ //同一个线程会直接自动获取到锁
System.out.println("level two");
}
}
可重入锁可以解决上述场景下出现的死锁问题
。
可重入锁在被上锁时会判断当前线程是否与持有锁的线程为同一个线程
,如果是则直接获取成功,计数器自增,释放锁时计数器自减。
Synchronized、ReentrantLock都是可重入锁,NonReentrantLock为非可重入锁。
公平与非公平
公平锁:多个线程获取锁的顺序按照其申请锁的顺序依次获取,先到先得
。公平锁不会出现饿死
的现象,但整体吞吐量相对非公平锁要低。
非公平锁:线程获取锁时直接去尝试获取锁,加锁失败进入队列等待。非公平会出现饿死线程
,但整体吞吐量相对公平锁要高。
在上述ReentrantLock的代码示例中,会先判断是否有其他线程在排队等待hasQueuedPredecessors()
,表明这是一个公平锁的代码。 ReentrantLock也有非公平锁的实现,如下:
可以看到,公平与非公平的唯一区别就在于是否会判断等待队列
。
偏向、轻量级与重量级
在JDK 1.6前,Synchronized关键字的加锁机制是依赖于底层的操作系统的Mutex Lock来实现线程同步
。这种加锁机制的锁被称为“重量级锁
”。
在重量级锁下,会出现大量线程的阻塞与唤醒操作
,导致CPU状态的频繁切换而消耗大量系统资源,从而带来性能问题
。
因此,在JDK 1.6 引入了偏向锁、轻量级锁来对Synchronized进行优化。
轻量级锁:在我们的大部分开发应用中,对共享对象进行长时间的独享占用属于少数情况。在大多数情况,共享对象处于非锁定或者短时间的锁定状态。
基于这种情况下,如果一直使用重量级锁对共享数据进行独享锁定,必然会导致性能上的下降。
轻量级锁使用CAS操作
将锁定对象的Mark Word
更新为指向当前线程栈帧中的Lock Record指针
,如果操作成功,则加锁成功,否则,则进入自旋状态
等待。
CAS操作避免了CPU状态的切换,从而节约了系统资源
。
:当一个线程在占用锁,另一个线程在自旋等待时,又有一个线程来竞争同一个锁时(即超过2个线程竞争锁时
),轻量级锁不在有效,会膨胀为重量级锁
。
轻量级锁解决了在没有多线程竞争的情况,因CPU状态切换导致的性能消耗
。
偏向锁:轻量级锁提高了效率,但是每次加锁依然会有CAS操作。偏向锁就是为了解决这个问题:在没有竞争的情况下,消除CAS同步原语
。 偏向锁,顾名思义,偏向于第一个加锁成功的线程。
在线程第一次通过CAS加锁成功后,如果锁没有被其他线程竞争,持有该锁的线程就不需要同步,自动获取锁
。
注意:当有其他线程尝试竞争偏向锁
时,偏向锁失效,膨胀为轻量级锁
。
锁消除与锁粗化
锁消除:虚拟机在编译期间,对代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁消除依赖的是编译期间的逃逸分析
(【Java虚拟机】内存分配与回收策略),堆上数据不会发生逃逸被其他线程所访问,就可以认为是线程私有的,同步锁自然可以消除。
锁粗化: 在编程中,我们推荐将锁的粒度尽量缩小,减少需要同步的数据量,降低锁等待。
但是假如一系列的连续操作都是对同一个对象反复加解锁
,甚至在循环体内进行加解锁
,就会频繁进行同步操作而消耗性能。
在这种情况下,虚拟机会将加锁的范围扩大(即粗化)
,降低同步频率以提高性能。