《Java并发编程之美》读书笔记
锁的概述
乐观锁与悲观锁
乐观锁和悲观锁是数据库中引入的名字,但是在并发包里面也引入了类似的思想。
悲观锁是指对数据被外界修改持保守态度,认为数据很容易就被其他线程修改,所以在处理数据之前会先对数据进行加锁。在整个数据处理的过程中,都使数据处于锁定状态,悲观锁的实现往往依靠数据库提供的锁机制,即在数据库中,在对数据记录进行操作前给记录增加排它锁,如果获取失败,则说明数据正在被其他线程锁修改,当前线程则等待或者抛出异常。如果获取锁成功,则对记录进行操作,然后提交事务后释放排它锁。
public int updateEntry(long id){
//使用悲观锁获取指定记录
EntryObject entry=query("select * from table1 where id=#{id} for update",id);
//修改记录内容,根据计算修改entry记录的属性
String name=generatorName(entry);
entry.setName(name);
//update操作
int count=update("update table1 set name=#{name},age=#{age} where id=#{id},entry");
return count;
}
对于如上代码,假设updateEntry,query,update方法都采用了事务切面的方法,并且事务的传播性被设置为了required。执行updateEntry方法时如果上层的调用方法里面没有开启事务,则会即使开启一个事务,然后执行代码1.代码1调用了query方法,其根据指定id从数据可里面查询出一个记录。由于事务的传播特性为required,所以只能够query时,没有开启新的事务,而是假如了updateEntry开启的事务,也就是在updateEntry方法执行完毕提交事务时,query方法才会被提交,就是说记录的锁定会持续到updateEntry执行结束。
接下来对获取的记录进行修改,再把修改的内容写回数据库,同样代码的update方法没有开启新的事务,而是假如了updateEntry的事务,也就是updateEntry,query,update方法共用的同一个事务。
当多个线程同时调用updateEntry方法的时候,并且传递的是同一个id时,只有一个线程执行代码会成功,其他线程则会被阻塞,这是因为在同一时间只有一个线程能获取到对应记录的锁,在获取锁的线程释放锁之前(updateEntry执行完毕,提交事务之前)其他线程必须等待,也就是在同一个线只有一个线程可以对该记录进行修改。
乐观锁
乐观锁相对于悲观锁来说,他认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。具体来说,根据update返回的行数让用户决定如何去做。
public int updateEntry(long id){
//使用乐观锁获取指定记录
EntryObject entry=query("select * from table1 where id=#{id} for update",id);
//修改记录内容,version字段不能被修改
String name=generatorName(entry);
entry.setName(name);
//update操作
int count=update("update table1 set name=#{name},age=#{age} ,version=${version}+1 where id=#{id} and version=#{version},entry");
return count;
}
在如上的代码中,当多个线程调用updateEntry方法并且传递相同的id时,多个线程可以同时执行代码获取id对应的记录并把记录放入线程本地栈里面,然后可以同时执行代码对自己栈上的记录进行修改,多个线程修改后各自的entry'里面的属性都不一样了。然后多个线程同时执行代码,大妈里面的update预计中的where条件加入了version=#{version},并且set语句里面多了version=${version}+1表达式,这个表达式的意思就是,如果数据库里面id=#{id} and version=#{version}的记录存在,就更新version的值为原来的值加上1,这有点CAS的意思。
假设多个线程同时执行updateEntry并传递相同的id,那么他们执行代码时获取的Entry时同一个,获取Entry里面的version都是相同的,当多个线程同时执行update,由于update语句本身是原子性的,假如线程A执行update成功了,那么这时候id对应的记录里面version的值加上了1,其他线程执行更新时发现数据库里面已经没有了version=0的语句,所以会返回影响的行号0,0在业务逻辑上就知道没有更新成功,那么接下来有两个做法,一是什么都不做,而是选择重试。
public boolean updateEntry(long id){
boolean result=false;
int retryNum=5;
while(retryNum>0){
//使用乐观锁获取指定记录
EntryObject entry=query("select * from table1 where id=#{id} for update",id);
//修改记录内容,version字段不能被修改
String name=generatorName(entry);
entry.setName(name);
//update操作
int count=update("update table1 set name=#{name},age=#{age} ,version=${version}+1 where id=#{id} and version=#{version},entry");
if(count==1){
result=true;
break;
}
retryNum--;
}
return reslut;
}
如上的代码retryNum设置更新失败后的重试次数,如果update操作返回0则说明记录已经被修改了,则循环一下,重新通过代码获取新的数据,再尝试更新,这种类似CAS的自旋操作。
乐观锁并不会使用数据库提供的锁机制,一般在表中添加version字段或者使用业务状态来实现。乐观锁直到提交才锁定,所以不会产生任何死锁。
公平锁和非公平锁
根据线程获取锁的枪战机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取到锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁,而非公品锁则在运行时闯入,也就是先来不一定先得。
ReentrantLock提供了公平锁和非公平锁的实现
- 公平锁:
ReentrantLock pairLock=new ReentrantLock(true);
- 非公平锁:
ReentrantLock unpairLock=new ReentrantLock(false);
如果构造函数不传递参数,则默认是非公平锁。
例如,假设线程A已经持有了锁,这时候线程B请求该锁则会被挂起。当线程A释放锁之后,假如当前有线程C也需要获取该锁如果采用非公平锁的方式,根据线程调度策略,线程B和线程C两则之一可能获取锁,这时候不需要任何其他的干涉,而如果使用公平锁则需要把C挂起,让B获取当前锁。
在没有公平性需求的情况下尽量使用非公平锁,因为公平锁会带了性能开销
独占锁与共享锁
根据所只能被单个线程持有还是能被多个线程持有,锁可以分为独占锁和共享锁。独占锁可以保证任何时候只有一个线程能或得该锁,ReentrantLock就是以独占锁的形式实现的,共享锁则可以被多个线程所持有,例如ReadWriteLock读写锁,它允许一个资源可以被多个线程同时进行读操作。
独占锁是一种悲观锁,由于每次访问资源先加上互斥所,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
可重入锁
当一个线程要获取一个被其他线程持有的独占锁时,这个线程会被阻塞,那么当一个线程再次获取它自己已经获取的锁时会不会被阻塞呢?如果不被阻塞,那这个锁就是可重入锁,也就是获取到了该锁之后,可以有限次数的进入该锁锁住的代码。
public class Hello {
public static synchronized void helloA(){
System.out.println("hello");
}
public static synchronized void helloB(){
System.out.println("hello b");
helloA();
}
public static void main(String[] args) {
Hello.helloB();
}
}
在如上的代码中,调用helloB方法前会获取内置锁,然后打印输出,之后调用hellA的方法,在调用之前会先回去内置锁,如果锁不是可重入的,那么调用线程则一直会被阻塞,实际上synchronized内部锁时可重入锁。
可重入锁的原理:在锁的内部维护一个线程标识,用来标示这个锁目前被哪个线程锁占用,然后关联一个计数器。一开始计数器的值为0,说明该锁没有被任何线程占用,当有一个线程获取到该锁的时候计数器的值就变为1,这时候其他线程再来获取该锁时防线锁的所有者不是自己所以会被阻塞挂起。
但是当获取该锁的线程再次获取锁时发现锁拥有者还是自己,就把计数器的值加上1,当释放锁的时候计数器的值-1;当计数器的值为0时,锁里面的线程标示被重置为null,这时候被阻塞的线程会被唤醒来竞争获取该锁。
自旋锁
由于java中的线程和操作系统中的线程是一一对应的,所以当一个线程在获取锁(如独占锁)失败的时候,会被切换到内核状态而被挂起。当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户态切换到内核状态的开销是特别大的,在一定程度上会影响并发性能。自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程所占有,它不会马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数时10,可以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其他线程已经释放了该锁。如果尝试了指定次数之后其他线程仍然还没有释放该锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用CPU时间来换取线程阻塞和调度的开销,但是很有可能这些CPU时间白白浪费了。
参考资料:
《Java并发编程之美》