Java的锁机制主要分内置锁(隐式锁)和显式锁。
内置锁
Java每个对象都有一个内置的锁对象,这些锁对象不需要显式地创建,可以直接对所有的Java对象加锁,所以也叫隐式锁。
虽然锁对象是存在的,但是只有对锁对象加锁才能保证同步性。从现实角度看这是很容易理解的,你拥有一把门锁,但是你没有锁上,别人依旧能推开门。
内置锁的加锁机制在JVM指令层面是通过monitorEnter和monitorExit来实现的,在操作系统层使用的是管程来实现互斥以及使用条件对象,在语言层面使用的是synchronized关键字,使用synchronized声明的方法,实例方法是对实例对象加锁,静态方法是对class对象加锁,synchronized块可以指定加锁的对象,实现比同步方法更加细粒度的加锁操作。
进入synchronized代码块即获取了锁,退出代码块(正常、异常)即释放了锁,这些操作是隐式的。
重入
获取了锁对象之后还能再次获取同一个锁,说明这个锁是可重入的。“重入”以为着锁的操作粒度是线程而非调用。
重入的一种实现方法是为每个锁关联一个获取计数值和一个所有者线程。计数值为0时,说明锁没有被线程持有,当线程请求一个未被占用的锁,JVM会记下锁的持有者,并给锁的计数器递增一,如果同一个线程再次获取这个锁(未释放锁其他线程获取不了),计数值自增,释放是递减,直到计数值为0,则线程释放了锁。也就是说,可重入锁的释放与重入要对应。
显式锁
Java5.0新增了一个新的锁机制:ReentrantLock(可重入锁)。这是一种显式锁,即需要显式地创建对象的锁对象。
可重入锁不是取代内置锁的,它是在内置锁不适用时,作为一种高级功能使用的锁。
Lock和ReentrantLock
ReentrantLock是Lock接口的实现。
Lock接口提供了一种无条件、可轮询、定时以及可中断的锁获取操作,所有加锁和解锁操作都是显式的。
ReentrantLock实现了Lock接口,并提供了与synchronized相同的互斥性和可见性。lock.lock()获取锁,和进入synchronized代码块有相同的内存语义,lock.unlock()释放锁,和离开synchronized代码块有相同的内存语义。
非阻塞的锁获取
tryLock()方法提供了可定时和可轮询的锁获取方法,可以有效地避免死锁的发生。
可中断的锁获取
lockInterruptibly方法在获取锁阻塞时可以被中断,被中断时会抛出InterruptedException。
非块结构的加锁
锁分段,使用多个锁保护一个大的数据结构,使得锁的粒度更小,降低锁的竞争。内置锁也支持锁分段。
公平性
在ReentrantLock的构造器可以指定公平性的设置,默认是非公平锁。公平锁的性能没有非公平锁高。
读写锁ReadWriteLock
读写锁内部有两个锁对象,读锁和写锁,读锁共享写锁独占。
在读写锁的加锁策略中,允许多个读同时执行,但是同一时刻只能有一个写。
ReentrantReadWriteLock
可重入读写锁提供了可重入的加锁语义。当访问以读取为主的数据结构时,读写锁能提高伸缩性。
在公平的锁中,如果一个锁由读线程持有,二另一个线程请求写入锁,那么其他读线程都不能获取读锁,知道写线程使用完释放了写锁。在非公平的锁中,线程获取访问许可的顺序是不定的。写线程降级为读线程是允许的,但是反过来不可以,读线程可以同时运行,如果同时请求写锁,容易造成死锁。
public class ReadWriteMap<K,V>{
private final Map<K,V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();
public ReadWriteMap(Map<K,V> map){
this.map = map;
}
public V put(K key,V value){
w.lock();
try{
return map.put(key,value);
}finally{
w.unlock();
}
}
public V get(Object key){
r.lock();// 避免读-写冲突,能和写锁互斥
try{
return map.get(key);
}finally{
r.unlock();
}
}
}
锁优化
JVM为高效并发做出的锁优化技术,为了在多线程之间更加高效地共享数据,以及减少数据竞争问题,从而提高程序执行效率。
主要技术有:
- 适应性自旋
- 锁清除
- 锁粗化
- 轻量级锁
- 偏向锁
自旋锁和适应性自旋
互斥同步对性能最大的影响是阻塞,阻塞会导致线程被挂起,挂起线程和恢复线程的操作都需要转入内核态来进行。
为了让线程等待锁的时候不被操作系统挂起,让线程执行一个忙循环,即自旋。如果等待的锁很快就被释放了,那么自旋是很有意义的,但是如果一直没有等到锁释放,这段占用时间片的无实际意义的自旋操作只是白白浪费资源而已。
自适应自旋是JVM会根据最近的等待时间自适应地去安排自旋的时间。
锁清除
锁清除是编译器的优化措施,当JIT在运行时发现某个加锁的操作只会被一个线程执行,就会清除这个锁。减少开销。
锁粗化
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁操作粗化到整个操作外层。
轻量级锁
轻量级锁是相对于使用系统信号量实现的传统锁而言的。
轻量级锁的用途是在没有多线程竞争的前提下,减少传统锁的性能消耗。
对象模型
Java对象保存在堆内存中。在内存中,一个Java对象包含三部分:对象头、实例数据和对齐填充。其中对象头是一个很关键的部分,因为对象头中包含锁状态标志、线程持有的锁等标志。
HotSpot虚拟机的对象头分为两部分信息,一部分保存对象自身的运行时数据,如hashcode、GC分代年龄等,这部分数据长度在32位虚拟机是32位的64位则是64位,官方称之为Mark Word。这是实现轻量级锁和偏向锁的关键。
另一部分存储的是指向方法区的对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数据长度。
在32位的Hotspot虚拟机中对象未被锁定的情况下,mark word的32位空间中25位存储hashcode,4位存储对象分代年龄,2位存储锁标志位,1为固定0,在其他状态下mw存储的内容如下表:
table-Hotspot虚拟机对象头Mark Word
存储内容 | 标志位 | 状态 |
---|---|---|
hashcode和分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 重量级锁定 |
空 | 11 | Gc标记 |
偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
轻量级锁获取执行逻辑:
- 成功进入同步块时(未锁定),锁标志位是01,虚拟机将在栈帧中建立一个叫锁记录(lock record)的空间,存储MarkWord的副本,叫做displaced mark word.
- jvm使用cas(compareAndSwitch)操作来尝试将对象的MarkWord更新为指向lock record的指针。如果更新操作成功,说明当前线程成功获取了锁,且对象的锁标志位变成00。如果更新操作失败了,jvm会检查对象的Mark Word是否指向当前线程的栈帧,如果是则说明当前线程以及拥有了对象锁(这个应该发生在重入时),否则说明这个锁被其他线程抢占了。
- 如果有两条线程抢一个锁,那轻量级锁就不再有效了,会膨胀成重量级锁,锁标志位变成10,后面等待锁的过程也要变成阻塞状态。
轻量级锁解锁逻辑: - 判断对象的MW是否仍指向线程的锁记录,是就用CAS操作把对象当前的MW和lock record中的displaced MW替换回来,替换成功就释放锁了。如果替换失败了,说明有其他线程尝试获取该锁,就要在释放锁的同时,唤醒被挂起的线程。
流程图:java轻量级锁原理.note
轻量级锁是使用cas去代替传统的操作信号量的来同步的方式。它能够提升性能的前提是在同步周期没有线程竞争锁,实际上对于大部分锁都是这个情况,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,如果存在竞争,除了互斥量开销还有CAS开销,那就会更加慢。
CAS 是处理器提供的非阻塞原子更新操作指令。
偏向锁
偏向锁的目的是消除数据在无竞争情况下的 同步原语,进而提高性能。
轻量级锁是避开使用OS的互斥量,偏向锁是把整个锁去除掉。偏向锁的偏向是偏向第一个获取它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将不需要再进行同步。
条件对象
有些情况下,在进入某个锁的临界区后,需要满足一些条件才能继续执行,于是主动释放锁,且阻塞直到被通知再被唤醒。
条件对象是基于锁机制上的一种协作机制,不满足条件时在主动阻塞直到锁条件对象发出通知才会被唤醒继续执行。
条件对象主要有内置条件对象和显式条件对象,内置条件对象时内置锁的条件对象,一个内置锁对象只能有一个条件对象;显式条件对象可以支持多个条件对象。
内置条件对象
正如每个Java对象都有一个内置的锁对象,都可以作为一个锁,每个对象同样可以作为一个条件对象。
Object类的wait()/wait(timeout)/wait(timeout,nanos),notify()和notifyAll()构成内置条件对象的API。
public class Object{
//...
public final native void notify();
public final native void notifyAll();
public final void wait() throws InterruptedException{//...}
public final native void wait(long timeout) throws InterruptedException;
public final void wait(long timeout, int nanos) throws InterruptedException{
if (timeout < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
if (nanos < 0 || nanos > 999999) {
throw new IllegalArgumentException(
"nanosecond timeout value out of range");
}
if (nanos > 0) {
timeout++;
}
wait(timeout);
}
}
对象的内置锁和其内部条件对象时相互关联的,要调用对象X中条件队列的任何一个方法,必须持有X上的锁。简单的说,调用条件对象的方法,必须在此对象的锁保护的同步块内部。
需要注意的是,wait()方法会释放当前持有的锁,阻塞当前线程,并等待直到超时(如果设置了超时,超时后会自己醒来),如何线程被中断(wait中的线程可被Interrupt且会抛出异常)或者被一个通知唤醒。唤醒线程后,线程从wait处继续执行(当然作为就绪态线程和其他线程一起竞争锁)。
notify从等待条件的线程队列中随机唤醒一个,notifyAll是唤醒队列中所有的。一般常用All,因为只唤醒一个可能会造成死锁。
显式条件对象
当对于一个锁等待不同的条件时,内置锁就无法实现了。显式条件对象Condition支持多个条件。
Condition的定义:
public interface Condition{
void await() throws Interruptedexception;
boolean await(long timeout,TimeUnit unit) throws Interruptedexception;
long awaitNanos(long nanostimeout) throws Interruptedexception;
void awaitUninterruptibly();
boolean awaitUntil(Date deadline) throws Interruptedexception;
void singal();
void singalAll();
}
使用显式条件变量的有界缓存
public class ConditionBoundedBuffer<T>{
protected final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final T[] items = (T[])new Object[BUFFER_SIZE];
private int tail,head,count;
//阻塞直到notFull
public void put(T x) throws Interruptedexception{
lock.lock();
try{
while(count == items.length)
notFull.await();
item[tail] = x;
if(++tail == item.length)
tail=0;
++count;
notEmpty.singalAll();
}finally{
lock.unlock();
}
}
// 阻塞直到notEmpty
public T take() throws Interruptedexception{
lock.lock();
try{
while(count==0)
notEmpty.await();
T x = item[head];
if(++head == items.length)
head=0;
--count;
notFull.singalAll();
return x;
}finally{
lock.unlock();
}
}
}