本文主要总结了Java多线程编程中各种锁的种类以及概述:
- 乐观锁与悲观锁
- 公平锁与非公平锁
- 独占锁和共享锁
- 可重入锁和不可重入锁
- 自旋锁
图为Java线程的生命周期.
Java 锁的概述
1. 乐观锁与悲观锁
乐观锁与悲观锁是数据库中引入的名词! 但是并发包中也引入了类似的思想.
1): 悲观锁
*概念: *
指对数据被外界修改持保守态度, 认为数据很容易就被其他线程修改, 所以在数据被处理之前要先对数据进行加锁, 并在整个数据处理过程中, 使数据处于锁定状态!
*实现: *
悲观锁的实现往往依靠数据库提供的锁机制, 即在对数据记录操作之前给记录增加排它锁.
- 若获取锁失败, 则说明数据正在被其他线程修改, 当前线程等待或者抛出异常;
- 若获取锁成功, 则对记录进行操作, 然后提交事务后释放排它锁.
例子: 使用悲观锁来避免多线程同时对一个记录进行修改
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
方法时, 如果上层调用方法没有开启事务, 则会即时开启一个事务, 然后执行代码!调用query方法, 由于事务传播性为
required
, 所以执行时没有开启新的事务, 而是加入了updateEntry
开启的事务;
也即是: 当updateEntry
方法执行完毕提交事务时, query方法才会被提交!
当多个线程同时调用updateEntry方法, 并且传递的是同一个id时, 只有一个线程执行代码会成功, 而其他线程将会被阻塞. 因为同一时间只有一个线程可以获取到对应记录的锁, 而在获取锁的线程释放锁之前(updateEntry执行完毕之前), 其他线程必须等待!
2): 乐观锁
*概念: *
乐观锁是相对悲观锁来说的, 他认为: 数据在一般情况下不会造成冲突, 所以在访问记录前不会增加排它锁! 而是在数据提交更新的时候, 才会正式对数据冲突与否进行检测!
*实现: *
具体来讲, 根据update返回的行数, 让用户决定如何去做!
*例子: *
public int updateEntry (long id) {
// 使用乐观锁获取记录(1)
EntryObject entry = query("SELECT * FROM table1 WHERE id = #{id}", id);
// 修改记录, version字段不能被修改!(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);
return count;
}
对于上述代码, 当多个线程使用updateEntry, 并且传递相同的id时:
多个线程可以同时执行代码(1)并获得id对应的记录, 然后放入线程本地栈中;
然后可以同时执行代码(2)对自己栈上的记录进行修改, 此时**多个线程修改后各自的entry中的属性都不同了!
然后多个线程同时执行代码(3), 代码(3)中的update语句的where条件加入了
version=#{version}
条件, 并且set语句中多了version=${version} + 1
表达式:若数据库中
id=#{id} && version=#{version}
的记录存在, 则更新version的值加1!
假设多个线程同时执行updateEntry并传入相同的id, 则执行(1)获取到的Entry是同一个! (Entry中的version是同一个!). 所以, 当执行(3)时:
由于update语句本身是原子性的, 假设某一个线程执行update成功了, 此时id对应的version由原始值变为+1. 则其他线程执行代码时, 会返回影响的行号为0!
业务上根据返回值0, 即可知道当前更新没有成功! 之后可以: 什么都不做, 或者选择重试:
// 使用乐观锁重试更新的例子
public boolean updateEntry(long id) {
boolean result = false;
int retryNum = 5;
while (retryNum > 0) {
// 使用乐观锁获取记录(1)
EntryObject entry = query("SELECT * FROM table1 WHERE id = #{id}", id);
// 修改记录, version字段不能被修改!(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);
if (count == 1) {
result = true;
break;
}
retryNum--;
}
return result;
}
上述代码使用retryNum设置了更新失败后的重试次数.
- 若执行后返回0: 说明记录已经被修改, 此时重新获得最新的数据, 然后重新尝试更新!
*总结: *
乐观锁并不会使用数据库提供的锁机制! 一般在表中添加version字段或者业务状态来实现!
乐观锁直到提交才锁定, 所以不会产生死锁!
2. 公平锁与非公平锁
根据线程获取锁的抢占机制, 分为: 公平锁和非公平锁
- 公平锁: 线程获取锁的顺序是按照线程请求锁的时间早晚来决定的, 先请求先得到;
- 非公平锁: 运行时闯入, 不一定先到的先得
*实现: *
ReentrantLock
提供了公平锁和非公平锁的实现:
公平锁:
ReentrantLock lock = new ReentrantLock(true);
非公平锁:
ReentrantLock lock = new ReentrantLock(false);
当构造函数不传参数默认为非公平锁!
举例:
线程A已经获得锁, 此时线程B请求该锁将被挂起. 当线程A释放锁后, 线程C也需要获得该锁.
- 若采用非公平锁方式, 根据线程调度, 线程B/C均有可能获得;
- 若使用公平锁, C将被挂起, 线程B获得锁.
3. 独占锁与共享锁
根据锁只能被单个线程持有, 还是能够被多个线程共同持有, 分为: 独占锁和共享锁
独占锁保证, 任何时候都只有一个线程能够得到锁, 如:
ReentrantLock
就是以独占方式实现;共享锁则可以同时由多个线程持有, 如:
ReadWriteLock
读写锁, 允许一个资源可以被多个线程同时进行读操作.
1): 独占锁
是一种悲观锁, 由于每次访问资源都要先加上互斥锁, 限制了并发性.
2): 共享锁
是一种乐观锁, 放宽了加锁的条件, 允许多个线程同时进行读操作.
4. 可重入锁
当一个线程要获取一个被其他线程持有的独占锁时, 线程将被阻塞; 而当一个线程再次获取他自己已经获取的锁时, 是否会被阻塞
如果不能被阻塞: 可重入锁. 即: 只要线程获取了该锁, 那么可以几乎无限次地进入该锁锁住的代码!
应用场景:
public class Hello {
public synchronized void helloA() {
System.out.println("hello");
}
public synchronized void helloB() {
System.out.println("helloB");
helloA();
}
}
如上代码块: 调用helloB方法后, 会先获得内置锁, 然后打印输出. 之后调用helloA方法, 在调用前会先获取该锁
如果锁是不可重入的, 那么线程将会一直被阻塞!
可重入内部锁: synchronized
事实上, synchronized内部锁是: 可重入的
.
原理:
在锁的内部维护一个线程标示, 用来标示该锁当前被哪个线程占用. 当一个线程获取了该锁时, 计数器的值变为1, 这时其他锁再来获取该锁时, 会发现锁的持有者不是自己而被阻塞挂起!
当获取了该锁的线程再次获取该锁时, 发现所拥有者是自己时, 会把计数器+1, 当释放锁后计数器-1;
当计数器值为零时, 锁中的线程标志被重置为null, 这时, 被阻塞的线程会被唤醒来竞争该锁!
5. 自旋锁
由于Java中的线程与操作系统中的线程是一一对应的, 所以:
- 当一个线程在获取锁(如独占锁)失败后, 会被切换到内核态而被挂起!
- 当一个线程获取锁后, 需要将其切换到内核态而唤醒该线程
从用户态切换到内核状态的开销是比较大的, 在一定程度上影响了并发的性能!
自旋锁则是:
当前线程获取锁时, 若发现锁已经被其他线程占有, 并不是马上阻塞自己! 而是在不放弃CPU使用权的前提下, 多次尝试(默认为10次, 可通过-XX:PreBlockSpinsh
参数设置). 可能在后面几次尝试中其他线程已经释放了该锁!
如果多次尝试之后仍没有获取到该锁, 则当前线程才会被阻塞挂起
即: 使用CPU时间换取线程阻塞和调度的开销, 但是有可能这些CPU时间白白浪费!