深入理解Java虚拟机(十一):各种锁概述

引言

在上一篇文章《深入理解Java虚拟机(十):线程安全问题和实现方法》中,我们提到了线程安全,不论是使用synchronized关键字或是使用Lock实现类等方法去保证线程安全,最核心的点就在于“锁”。

在Java并发编程中,有很多关于锁的名词,例如:乐观锁、悲观锁、可重入锁、自旋锁、轻量级锁等等,本篇文章将会逐个总结,各种锁的含义,看这一篇文章就够了!

分类

在详细解释各种锁之前,我们先从宏观上把握,从不同的角度,将各种锁进行划分整理一下。

  • 从线程是否要锁住同步资源角度,可以分为乐观锁和悲观锁
  • 根据线程获取锁的抢占机制,可分为公平锁和非公平锁
  • 从多个线程能否共享同一把锁的角度,可分为共享锁和独占锁
  • 从一个线程多个流程能否获取同一把锁来看,分为可重入锁和不可重入锁
  • 若锁住同步资源失败,线程是否要阻塞,若不阻塞,可以分为自旋锁和自适应自旋
  • 从多个线程竞争同步资源的过程看,存在无锁、偏向锁、轻量级锁和重量级锁

乐观锁和悲观锁

乐观锁和悲观锁是在数据库中引入的名词,java并发编程包锁中也引入了类似的思想。

悲观锁是指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态

悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录操作前给记录加排它锁。如果获取锁失败,则说明数据正在被其他线程修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。

下面通过一段伪代码来看看悲观锁的实现原理:

public int updateUser(long id){
	//(1)使用悲观锁获取指定记录
	User user = query("select * from user where id = #{id} for update", id);
	//(2)修改记录属性
	String newName = generateName(user.getName);
	user.setName(newName);
	//(3)update操作
	int count = update("update user set name=#{name} where id = #{id}");
	return count;
}

上述代码updateUser、query、update三个方法共用的是同一个事务,当多个线程调用updateUser方法,并且传入的是同一个id时,只有一个线程会执行代码(1)成功,其他线程会被阻塞,因为在同一时间只有一个线程可以获取对应记录的锁,在获取锁的线程释放锁前(updateUser执行完毕,提交事务前),其他线程必须等待,也就是在同一时间只有一个线程可以对该记录进行修改。

乐观锁是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。

下面我们接着用上面的例子改为乐观锁实现,伪代码如下:

public int updateUser(long id){
	//(1)使用乐观锁获取指定记录
	User user = query("select * from user where id = #{id}", id);
	//(2)修改记录属性
	String newName = generateName(user.getName);
	user.setName(newName);
	//(3)update操作
	int count = update("update user set name=#{name}, version=#{version} +1 where id = #{id} and version=#{version}");
	return count;
}

在上述代码中,如果有多个线程调用updateUser方法并且传入相同的id时,多个线程可以同时执行代码(1)获取id对应的记录并把记录放入线程本地栈里面,然后可以同时执行代码(2)对自己栈上的记录进行修改。然后多个线程可以同时执行代码(3),代码(3)中的update语句的where条件里面加入了version=#{version}条件,并且set语句中多了version=${version}+1表达式,该表达式的意思是,如果数据库里面id =#{id}and version=#{version}的记录存在,则更新version的值为原来的值加1。

当多个线程执行代码(3)时,由于update语句本身是原子性的,假如线程A执行update成功了,那么这时候id对应的记录的version值由原始version值变为了1。其他线程执行代码(3)更新时发现数据库里面已经没有了version=0的语句,所以会返回影响行号0。

在业务上根据返回值为0就可以知道当前更新没有成功,那么接下来有两个做法,如果业务发现更新失败了,下面可以什么都不做,也可以选择重试,如果选择重试,则updateUser的代码可以修改为如下:

public boolean updateUser(long id){
    boolean result = false;
    int retryNum = 5;
    while(retryNum>0){
		//(1)使用乐观锁获取指定记录
		User user = query("select * from user where id = #{id}", id);
		//(2)修改记录属性
		String newName = generateName(user.getName);
		user.setName(newName);
		//(3)update操作
		int count = update("update user set name=#{name}, version=#{version} +1 where id = #{id} and version=#{version}");
		if(count ==1){
			result = true;
			break;
		}
		retryNum--;
	}
	return result;
}

乐观锁并不会使用数据库提供的锁机制,一般在表中添加version字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。

公平锁和非公平锁

公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则在运行时闯入,也就是先来不一定先得。

ReentrantLock提供了公平和非公平锁的实现。

  • 公平锁:ReentrantLock pairLock = new ReentrantLock(true)。
  • 非公平锁:ReentrantLock pairLock = new ReentrantLock(false)。如果构造函数不传递参数,则默认是非公平锁。

例如,假设线程A已经持有了锁,这时候线程B请求该锁其将会被挂起。当线程A释放锁后,假如当前有线程C也需要获取该锁,如果采用非公平锁方式,则根据线程调度策略,线程B和线程C两者之一可能获取锁,这时候不需要任何其他干涉,而如果使用公平锁则需要把C挂起,让B获取当前锁。

在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。

独占锁和共享锁

独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock就是以独占方式实现的。共享锁则可以同时由多个线程持有,例如ReadWriteLock读写锁,它允许一个资源可以被多线程同时进行读操作。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。

可重入锁

当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时是否会被阻塞呢?如果不被阻塞,那么我们说该锁是可重入的,也就是只要该线程获取了该锁,那么可以多次重复地进入被该锁锁住的代码。

实际上,synchronized内部锁就是可重入锁。可重入锁的原理是在锁内部维护一个线程标示,用来标示该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁没有被任何线程占用。当一个线程获取了该锁时,计数器的值会变成1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被阻塞挂起。

但是当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值加+1,当释放锁后计数器值-1。当计数器值为0时,锁里面的线程标示被重置为null,这时候被阻塞的线程会被唤醒来竞争获取该锁。

自旋锁和自适应自旋

自旋锁是指当一个线程在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待,然后不断判断是否能够被成功获取,自旋直到获取到锁才会退出循环。

自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中就已经改为默认开启了。

如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。

在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。

无锁、偏向锁、轻量级锁、重量级锁

深入理解Java虚拟机(十一):各种锁概述_第1张图片
随着竞争不断的加剧,锁要不断的升级。

不同的锁状态下,HotSpot虚拟机对象头Mark Word存储的内容也不同,具体如下图所示:
深入理解Java虚拟机(十一):各种锁概述_第2张图片

  • 对象未被锁定的状态下,Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。
  • 在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。
  • 如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。
  • 偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

你可能感兴趣的:(JVM,java,并发编程)