java 线程锁: synchronized、 Reentran
synchronized
-
synchronized是Java的一个关键字,它能够将代码块或方法锁起来,synchronized是一种互斥锁,一次只能允许一个线程进入被锁住的代码块
-
synchronized,通过使用内置锁(Java中每个对象都有一个内置锁/监视器,也可以理解成锁标记,对象有自己的对象头,存储了很多信息,其中一个信息标示是被哪个线程持有),来实现对变量的同步操作,进而实现了对变量操作的原子性(被保护的代码块是一次被执行的,没有任何线程会同时访问)和其他线程对变量的可见性(当执行完synchronized之后,修改后的变量对其他的线程是可见的),从而确保了并发情况下的线程安全。
-
获取了类锁的线程和获取了对象锁的线程是不冲突的
synchronized一般我们用来修饰三种东西:
1.修饰普通同步方法,锁是当前实例对象
2.修饰同步代码块,锁是括号里面的对象
3.修饰静态同步方法,锁是当前类
使用:
void resource1() {
synchronized ("resource1") {
System.out.println("作用在同步块中");
}
}
synchronized void resource3() {
System.out.println("作用在实例方法上");
}
static synchronized void resource2() {
System.out.println("作用在静态方法上");
}
释放锁的时机
- 当方法(代码块)执行完毕后会自动释放锁,不需要做任何的操作。
- 当一个线程执行的代码出现异常时,其所持有的锁会自动释放,不会由于异常导致出现死锁现象。
JDK1.6后的Synchronized
JDK1.6开始Synchronized锁就做了各种的优化,优化操作:适应自旋锁,锁消除,锁粗化,轻量级锁,偏向锁。
Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和释放锁的效率。
Java对象头
synchronized使用的锁存在Java对象头里。如果对象是数组类型,则虚拟机用3个Word(机器码)存储对象头(因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。),如果对象是非数组类型,则用2个机器码存储对象头。在32位虚拟机中,一个机器码等于四字节,即32bit。
Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。
Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Java对象头_Mark Word:
Java对象头里的Mark Word里默认存储对象的HashCode,分代年龄和锁标记位。
(无锁状态 : 对象的HashCode + 对象分代年龄 + 状态位001)
32位JVM的Mark Word的默认存储结构如下:
无锁状态.png
在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:
Mark Word变化.png
Monitor
什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。
-
当且一个Monitor被持有后,它将处于锁定状态。
-
Synchronized在JVM里的实现是基于进入和退出Monitor对象来实现方法同步和代码块同步 :
进入:
MonitorEnter指令插入在同步代码块的开始位置,当代码执行到该指令时,将会尝试获取该对象Monitor的所有权,即尝试获得该对象的锁。
退出:
monitorExit指令则插入在方法结束处和异常处。
- JVM保证每个MonitorEnter必须有对应的MonitorExit。
Monitor Record:
-
Monitor Record是线程私有的数据结构
-
每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。
-
每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
Monitor Record的内部结构:
Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
Nest:用来实现重入锁的计数。
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。
偏向锁
大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
获取锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS(Compare and Swap)操作来加锁和解锁,获取锁只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁:
- 如果测试成功,表示线程已经获得了锁。
- 如果测试失败
2.1 测试Mark Word中偏向锁的标识是否是1(1表示当前是偏向锁),则尝试使用CAS(Compare and Swap)将对象头的偏向锁指向当前要获取锁的线程。
2.2 如果没有设置,则使用CAS竞争锁
注:CAS(Compare and Swap):比较并交换。用于在硬件层面上提供原子性操作。在 Intel 处理器中,比较并交换通过指令cmpxchg实现。比较是否和给定的数值一致,如果一致则修改,不一致则不修改。
撤销锁
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行):
- 首先暂停拥有偏向锁的线程
- 然后检查持有偏向锁的线程是否活着
2.1 如果线程不处于活动状态,则将对象头设置成无锁状态
2.2 如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁 - 最后唤醒暂停的线程。
关闭偏向锁
偏向锁是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态
轻量级锁
这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒。
加锁:
(1)当对象处于无锁状态时(RecordWord值为HashCode,状态位为001),线程首先从自己的可用moniter record列表中取得一个空闲的moniter record,初始Nest设置为1、Owner值被设置该线程自己的标识,一旦monitor record准备好然后我们通过CAS原子指令安装该monitor record的起始地址到对象头的LockWord字段,如果存在其他线程竞争锁的情况而调用CAS失败,则只需要简单的回到MonitorEnter重新开始获取锁的过程即可。
(2)对象已经被膨胀同时Owner中保存的线程标识为获取锁的线程自己(同一个线程再次获取这把锁),这就是重入(reentrant)锁的情况,只需要简单的将Nest加1即可。不需要任何原子操作,效率非常高。
(3)对象已膨胀但Owner的值为NULL(当一个锁上存在阻塞或前一个这个锁的拥有者刚释放锁时会出现这种状态),此时多个线程通过CAS原子指令在多线程竞争状态下试图将Owner设置为自己的标识来获得锁,竞争失败的线程在则会进入到第四种情况(4)的执行路径。
(4)对象处于膨胀状态同时Owner不为NULL(被锁住),在调用操作系统的重量级的互斥锁之前先自旋一定的次数,当达到一定的次数时如果仍然没有成功获得锁,则开始准备进入阻塞状态,首先将rfThis的值原子性的加1,由于在加1的过程中可能会被其他线程破坏Object和monitor record之间的关联,所以在原子性加1后需要再进行一次比较以确保LockWord的值没有被改变,当发现被改变后则要重新MonitorEnter过程。同时再一次观察Owner是否为NULL,如果是则调用CAS参与竞争锁,锁竞争失败则进入到阻塞状态。
解锁:
轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
不同锁的比较.png整个synchronized锁流程如下:
检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
如果自旋成功则依然处于轻量级状态。
如果自旋失败,则升级为重量级锁。
volatile与synchronized的区别
- volatile仅能使用在变量级别; synchronized则可以使用在变量、方法、和类级别的
- volatile仅能实现变量的修改可见性,并不能保证原子性;synchronized则可以保证变量的修改可见性和原子性
- volatile不会造成线程的阻塞; synchronized可能会造成线程的阻塞。
- volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
lock接口
AQS
java.util.concurrnt.lock包下有三个抽象的类:
- AbstractOwnableSynchronizer、
- AbstractQueuedLongSynchronizer、
- AbstractQueuedSynchronizer
其中AbstractQueuedSynchronizer简称为AQS
AQS其实就是一个可以给我们同步锁、同步器的框架,一般我们叫AQS为同步器,juc包(java.util.concurrnt的缩写)中很多可阻塞的类都是基于AQS构建的,子类只要重写部分的代码即可实现(大量用到了模板代码)。
- 内部实现的关键是:先进先出的队列、state状态
- 定义了内部类ConditionObject
- ReentrantLock、 ReadWriteLock都是基于AQS来构建
- 拥有两种线程模式
独占模式
共享模式
AQS主要提供了如下一些方法:
getState():返回同步状态的当前值;
setState(int newState):设置当前同步状态;
compareAndSetState(int expect, int update):使用CAS设置当前状态,该方法能够保证状态设置的原子性;
tryAcquire(int arg):独占式获取同步状态,获取同步状态成功后,其他线程需要等待该线程释放同步状态才能获取同步状态;
tryRelease(int arg):独占式释放同步状态;
tryAcquireShared(int arg):共享式获取同步状态,返回值大于等于0则表示获取成功,否则获取失败;
tryReleaseShared(int arg):共享式释放同步状态;
isHeldExclusively():当前同步器是否在独占式模式下被线程占用,一般该方法表示是否被当前线程所独占;
acquire(int arg):独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用可重写的tryAcquire(int arg)方法;
acquireInterruptibly(int arg):与acquire(int arg)相同,但是该方法响应中断,当前线程为获取到同步状态而进入到同步队列中,如果当前线程被中断,则该方法会抛出InterruptedException异常并返回;
tryAcquireNanos(int arg,long nanos):超时获取同步状态,如果当前线程在nanos时间内没有获取到同步状态,那么将会返回false,已经获取则返回true;
acquireShared(int arg):共享式获取同步状态,如果当前线程未获取到同步状态,将会进入同步队列等待,与独占式的主要区别是在同一时刻可以有多个线程获取到同步状态;
acquireSharedInterruptibly(int arg):共享式获取同步状态,响应中断;
tryAcquireSharedNanos(int arg, long nanosTimeout):共享式获取同步状态,增加超时限制;
release(int arg):独占式释放同步状态,该方法会在释放同步状态之后,将同步队列中第一个节点包含的线程唤醒;
releaseShared(int arg):共享式释放同步状态;
性质
独占锁(排它锁):
只能有一个线程获取锁
重入锁:
可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
在JAVA环境下 ReentrantLock 和synchronized 都是可重入锁。可重入锁最大的作用是避免死锁。
公平锁
直接加入同步队列,线程将按照它们发出请求的顺序来获取锁
非公平锁
线程发出请求的时可以“插队”获取锁,若成功立刻返回,失败则加入同步队列
Lock和synchronize都是默认使用非公平锁的(如果不是必要的情况下,不要使用公平锁
公平锁会来带一些性能的消耗的)。
锁实现
由Node节点组成一条同步队列(有head,tail两个指针,并且head初始化时指向空节点)
int state标记锁使用数量(独占锁时,通常为1,发生重入时>1)
lock()时加到队列尾部
unlock()时,释放head节点,并指向下一个节点head=head.next,然后唤醒当前head节点
Lock接口定义锁的行为
Lock方式来获取锁支持中断、超时不获取、是非阻塞的
提高了语义化,哪里加锁,哪里解锁都得写出来
Lock显式锁可以给我们带来很好的灵活性,但同时我们必须手动释放锁
支持Condition条件对象
允许多个读线程同时访问共享资源
public interface Lock {
//上锁(不响应Thread.interrupt()直到获取锁)
void lock();
//上锁(响应Thread.interrupt())
void lockInterruptibly() throws InterruptedException;
//尝试获取锁(以nonFair方式获取锁)
boolean tryLock();
//在指定时间内尝试获取锁(响应Thread.interrupt(),支持公平/二阶段非公平)
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//解锁
void unlock();
//获取Condition
Condition newCondition();
}
Lock锁主要的两个子类:
ReentrantLock
ReentrantReadWriteLock
注:以下内容介绍这两个子类
ReentrantLock 重入锁
重入锁,表示在单个线程内,这个锁可以反复进入,也就是说,一个线程可以连续两次获得同一把锁。
ReentrantLock有三个内部类:Sync、NonfairSync、FairSync(Sync类继承自AbstractQueuedSynchronizer抽象类)
内部类.png
使用时最标准用法是在try之前调用lock方法,在finally代码块释放锁
class X {
private final ReentrantLock lock = new ReentrantLock();//非公平锁
public void m() {
lock.lock(); // block until condition holds
try {
// ... method body
} finally {
lock.unlock()
}
}
}
重入锁相比 synchronized 有哪些优势:
- 可以在线程等待锁的时候中断线程,synchronized 是做不到的。
- 可以尝试获取锁,如果获取不到就放弃,或者设置一定的时间,这也是 synchroized 做不到的。
- 可以设置公平锁,synchronized 默认是非公平锁,无法实现公平锁。
默认构造方法是非公平锁
//源码
public ReentrantLock() {
// 默认非公平策略
sync = new NonfairSync();
}
公平锁构造方法:传入true
//源码
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
非公平锁方式lock()方法尝试获取锁
//源码
final void lock() {
if (compareAndSetState(0, 1)) // 比较并设置状态成功,状态0表示锁没有被占用
// 把当前线程设置独占了锁
setExclusiveOwnerThread(Thread.currentThread());
else // 锁已经被占用,或者set失败
// 以独占模式获取对象,忽略中断
acquire(1); //获取失败的话就调用AQS的acquire(1)方法
}
公平锁方式lock()方法尝试获取锁
final void lock() {
// 以独占模式获取对象,忽略中断
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
// 尝试公平获取锁
protected final boolean tryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取状态
int c = getState();
if (c == 0) { // 状态为0
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) { // 不存在已经等待更久的线程并且比较并且设置状态成功
// 设置当前线程独占
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 状态不为0,即资源已经被线程占据
// 下一个状态
int nextc = c + acquires;
if (nextc < 0) // 超过了int的表示范围
throw new Error("Maximum lock count exceeded");
// 设置状态
setState(nextc);
return true;
}
return false;
}
资源被占用,公平锁方式的lock()获取锁方法调用如下,只给出了主要的方法:
FairSync类的lock的方法调用.png
公平的lock方法其实就多了一个状态条件:hasQueuedPredecessors()
这个方法主要是判断当前线程是否位于CLH同步队列中的第一个。如果是则返回flase,否则返回true。
可以看出只要资源被其他线程占用,该线程就会添加到sync queue中的尾部,而不会先尝试获取资源。这也是和非公平锁最大的区别,非公平锁每一次都会尝试去获取资源,如果此时该资源恰好被释放,则会被当前线程获取,这就造成了不公平的现象,当获取不成功,再加入队列尾部。
ReentrantLock的unluck()方法
调用sync的release()
ReentrantReadWriteLock读写锁
ReadWriteLock接口
他的标准实现类是ReentrantReadWriteLock类,ReentrantReadWriteLock类和普通重入锁一样,也能实现公平锁,中断响应,锁申请等特性。因为他们返回的读锁或者写锁都实现了 Lock 接口。
ReadWriteLock接口定义的方法就两个:
- Lock readLock(); 返回一个读锁
- Lock writeLock(); 返回一个写锁
规则
- 读锁不支持条件对象,写锁支持条件对象
- 读锁不能升级为写锁,写锁可以降级为读锁
- 读写锁也有公平和非公平模式
- 读锁支持多个读线程进入临界区,写锁是互斥的
ReentrantReadWriteLock比ReentrantLock多了两个内部类(都实现了Lock接口)来维护读锁和写锁:
- WriteLock
- ReadLock
使用
static ReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
static Lock readLock = reentrantReadWriteLock.readLock();
static Lock writeLock = reentrantReadWriteLock.writeLock();
悲观锁和乐观锁
悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
乐观锁:假定不会发生并发冲突,只在提交操作时检测是否违反数据完整性。(使用版本号或者时间戳来配合实现)
锁优化
-
减少锁持有时间
需要线程安全的代码放进同步代码块中 -
减少锁粒度
将大对象拆成更小粒度的小对象加锁,如ConCurrentHashMap -
锁分离
读写锁的基本思想是将读与写进行分离,因为读不会改变数据,所以读与读之间不需要进行同步,只要有写锁进入就需要做同步处理,但是对于大多数应用来说,读的场景要远远大于写的场景,因此一旦使用读写锁,在读多写少的场景中,就可以很好的提高系统的性能。如:LinkedBlockingQueue,从头部拿数据(读),添加数据(写)则在尾部,读与写这两者操作的数据在不同的部位,因此可以同时进行操作,使并发级别更高,除非队列或链表中只有一条数据。这就是读写分离思想的进一下延伸:只要操作不相互影响,锁就可以分离。 -
锁粗化
一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源。锁粗化就是把很多次锁的请求合并成一个请求,降低短时间内大量锁请求、同步、释放带来的性能损耗。