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