并发锁
锁是用于对于共享资源访问控制的工具,常见的有Lock
和synchronized
,都是可以达到线程安全的目的,但是使用上和功能上有所不同
Lock接口
Lock
不会想synchronized
那样在异常时自动释放锁,获取锁后的try
语句块儿中编写业务逻辑,在finally
语句块儿中有释放锁的操作,以避免死锁的发生
Lock
的加锁解锁和synchronized
有同样的内存语义,也就是说遵守happens-before原则,下一个线程加锁后可以看到所有前一个线程解锁前发生的所有操作
方法名 | 描述 |
---|---|
void lock() |
获取锁,等锁期间不会被中断,永久等待 |
void lockInterruptibly() |
获取锁,等锁期间会被中断,永久等待 |
boolean tryLock() |
尝试获取锁,立刻返回,获取到返回true,获取不到返回false |
boolean tryLock(long time, TimeUnit unit) |
尝试获取锁,指定时间内获取到锁返回true,超时获取不到返回false |
void unlock() |
释放锁 |
锁的分类
锁的分类是从不同角度去看,这些分类不是互斥的,就可以共存的,也就是说同一个锁可以属于多种类型
- 线程释放锁住同步资源
- 锁住:悲观锁
- 不锁住:乐观锁
- 多线程能否共享一把锁
- 可以:共享锁
- 不可以:独占锁
- 多线程竞争时是否允许插队
- 不允许插队:公平锁
- 允许插队:不公平锁
- 同一个线程是否可以重复获取同一把锁
- 可以:可重入锁
- 不可以:不可重入锁
- 等锁期间是否可中断
- 可以:可中断锁
- 不可以:不可中断锁
- 等锁过程中线程是否阻塞
- 线程不停的尝试获取锁,也就是自旋:自旋锁
- 阻塞:非自旋锁
乐观锁和悲观锁
乐观锁和悲观锁也称为非互斥同步锁和互斥同步锁
乐观锁认为:自己在处理操作时不会有其他线程来感染,所以并不会锁住被操作对象,在更新数据时,去对比在修改期间数据是否被别的线程修改过,若没有被修改过则正常执行,若被修改过则可选择放弃修改、报错、重试等策略
Java中乐观锁实现就是原子类、并发容器等,乐观锁执行流程如下:
- 多个线程直接获取资源并各自计算,并未给资源加锁
- 优先计算完的线程判断资源是否被修改过,若没有则将计算的结果写入资源中
- 之后计算玩的线程判断资源已经被修改,则会执行放弃修改、报错、重试等设置的策略
悲观锁认为:若不锁住资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,将数据锁住,别的线程就无法访问该数据,这样就可以保证数据的正确性
Java中的悲观锁实现就是synchronized
和Lock
相关类,悲观锁的执行流程如下:
- 多个线程同时抢一把锁,只有一个线程能抢到,未抢到的线程就需要等待
- 抢到锁的线程将锁释放后,后续线程接着抢
- 直到所有线程都执行完毕
乐观锁和悲观锁对比
悲观锁会带来以下问题:所以适用于并发写入少,大部分是读取场景,不加锁就能让读取性能大幅度提高
- 若自旋时间很长或不停重试,那么消耗资源也会越来越多
悲观锁会带来以下问题:所以适用于并发写入多的情况,适用于临界区持锁时间比较长(IO操作、代码复杂循环量大、竞争激烈)的情况,悲观锁可以避免长时间无用自旋等消耗
- 性能损耗:被悲观锁锁住后,其他想获取相同资源就必须等待,线程阻塞和唤醒会带来性能问题,比如上下文切换、操作系统用户态和心态切换、检查是否有阻塞线程要被唤醒等
- 可能永久阻塞:若持有锁的线程因为陷入死循环、死锁等活跃性问题,无法释放锁,那么等待的线程就会永久阻塞
- 优先级反转:若持有锁的线程优先级低,未持有锁的优先级高,若持有锁优先级低的线程长时间不释放锁,就会导致优先级高的线程也得不到执行
可重入锁和不可重入锁
可重入锁又称为递归锁
可重入性质:多个线程可多次获取同一把锁
- 可重入锁原理:获取锁时先判断,若当前线程已经占有该锁,则获取锁该锁的次数加1;释放说时也是先判断,若当前线程获取该锁的次数若等于0,才真正的释放
- 不可重入锁原理:获取锁时直接将该锁设置为已被占有状态,当再次获取时就无法获取;释放锁时也是直接设置为未被占有状态
Java中可重入锁有synchronized
和ReentrantLock
;Java中不可重入锁有ThreadPoolExecutor.Worker
类
private static ReentrantLock reentrantLock = new ReentrantLock();
private static void accessResource() {
reentrantLock.lock(); //加锁
try {
if (reentrantLock.getHoldCount() < 5) {
System.out.println("加锁次数:" + reentrantLock.getHoldCount());
accessResource(); //递归调用
System.out.println("加锁次数:" + reentrantLock.getHoldCount());
}
} finally {
reentrantLock.unlock(); //解锁
}
}
public static void main(String[] args) {
accessResource();
}
公平锁和非公平锁
公平是指按照线程请求顺序来分配锁;非公平是指不完全按照线程请求顺序来分配锁,在一定情况下允许插队
- 公平锁的原理:获取锁之前先判断是否有其他线程在排队,若没有才尝试获取锁
- 非公平锁的原理:不管是否有是否有其他线程在排队,都尝试获取锁
对比公平锁和非公平锁
- 公平锁
- 优点:每个线程等待一段时间后,总有执行的集汇
- 缺点:存在空档期,更慢,吞吐量小
- 不公平锁
- 优点:不存在空档期,更快,吞吐量大
- 缺点:有可能产生线程饥饿现象,比如某些线程长时间被其他线程插队而得不到执行
Java中的锁默认就是非公平锁,之所以这样设计是因为,这样可以避免唤醒线程带来的空档期,从而提高效率,具体插队的策略从下面代码可以看出
tryLock()
方法不遵守设定的公平规则,当线程执行tryLock()
方法时,一旦有线程释放了锁,那么该线程就能立刻获得到锁,即使已经有其他线程在等待队列中了
/**
* 公平锁的情况下:同一个线程在第二次获取锁时也会被排到最后
* 非公平锁的情况下:同一个线程在第二次获取锁时会直接获取,也就是插队现象
*/
//private static ReentrantLock reentrantLock = new ReentrantLock(); //默认是非公平锁
private static ReentrantLock reentrantLock = new ReentrantLock(true); //传入true是公平锁
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
Random random = new Random();
reentrantLock.lock(); //第一次上锁
try {
System.out.println(Thread.currentThread().getName() + "第一次上锁");
Thread.sleep(random.nextInt(3000)); //等一会再释放锁
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLock.unlock();
}
reentrantLock.lock(); //第二次上锁
try {
System.out.println(Thread.currentThread().getName() + "第二次上锁");
Thread.sleep(random.nextInt(3000)); //等一会再释放锁
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
reentrantLock.unlock();
}
}).start();
Thread.sleep(10); //保证线程的启动顺序
}
}
共享锁和排他锁
排他锁:又称为独占锁、独享锁,获取该锁后,既能读又能写,但是其他线程无法获取排他锁
共享锁:又称为读锁,获取该锁后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,同时也可以查看但无法修改和删除
若在读的地方使用读锁,在写的地方使用写锁,灵活控制,在没有写锁的情况下,读是无阻塞的,可大大提高程序的执行效率,共同使用共享锁和排他锁具有如下规则:简单的说要么同时读,要么一个线程写,但两者不会同时出现(要么多读,要么一写)
- 多个线程只申请读锁都可以申请到
- 若有一个线程已经占有读锁,则此时其他线程申请写锁需要等待读锁释放
- 多有一个线程已经占有写锁,则此时其他线程如果申请写锁或读锁需要等待写锁释放
Java中共享锁和排他锁就是ReentrantReadWriteLock
,其中读锁是共享锁,写锁是排他锁
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); //该方法返回读写锁的读锁
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock(); //该方法返回读写锁的写锁
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
new Thread(() -> read()).start();
new Thread(() -> read()).start();
new Thread(() -> write()).start();
new Thread(() -> write()).start();
}
读线程插队性质
公平的读写锁:不允许读锁写锁插队
非公平的读写锁:若允许读锁插队不允许写锁插队虽然可提高程序执行效率,但是可能会造成写锁线程饥饿,所以Java中的ReentrantReadWriteLock
使用下面的插队策略
- 写锁随时可插队,之所以这样是因为读锁可以共存,写锁不能共存,就不容易插队,就不容易造成饥饿现象
- 读锁仅在等待队列头节点不是想获取写锁的线程时才可以插队,之所以这样是因为可以减少空档期的时间浪费
public class Main {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(); //默认是非公平锁
//private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true); //传入true是公平锁
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); //该方法返回读写锁的读锁
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock(); //该方法返回读写锁的写锁
private static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
/**
* 公平锁的情况下:不允许任何锁插队
* 非公平锁的情况下:若头节点是读线程时允许读线程插队;写线程可随时插队
*/
//writeBarge(); //写线程插队
readBarge(); //读线程插队
}
public static void readBarge() {
Thread[] thread = new Thread[1000];
for (int i = 0; i < 1000; i++) {
thread[i] = new Thread(() -> read(), "子线程" + i); //将子线程创建放在最外面,以防对象创建影响线程执行顺序
}
new Thread(() -> write()).start();
new Thread(() -> read()).start();
//此处空隙有一瞬间等待队列中头节点是读线程,也就是这里直接可以有子线程插队
new Thread(() -> read()).start();
new Thread(() -> write()).start(); //多运行几次,让这个写线程在后面更明显
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
thread[i].start();
}
}).start(); //子线程中启动很多读线程,可插队到写线程之前
}
public static void writeBarge() {
Thread[] thread = new Thread[1000];
for (int i = 0; i < 1000; i++) {
thread[i] = new Thread(() -> read(), "子线程" + i); //将子线程创建放在最外面,以防对象创建影响线程执行顺序
}
new Thread(() -> write()).start();
new Thread(() -> {
for (int i = 0; i < 1000; i++) {
thread[i].start();
}
}).start(); //子线程中创建很多读线程,下面写线程可以插队到读子线程之前
new Thread(() -> write()).start();
}
}
锁的升降级
写锁可以降级为读锁,读锁不可以升级为写锁,之所以这样设计是为了避免升级造成的死锁,比如:要知道必须等所有的读锁都释放才能进行升级成写锁,若多个线程都想升级成写锁,都不释放自己的读锁,就会造成死锁
public class Main {
private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void readUpgrading() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了读锁,正在读取");
Thread.sleep(1000);
System.out.println("升级会带来阻塞");
writeLock.lock();
System.out.println(Thread.currentThread().getName() + "获取到了写锁,升级成功");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void writeDowngrading() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到了写锁,正在写入");
Thread.sleep(1000);
readLock.lock();
System.out.println("在不释放写锁的情况下,直接获取读锁,成功降级");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
//readUpgrading();
writeDowngrading();
}
}
自选锁和阻塞锁
阻塞或唤醒一个线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费CPU的时间,若同步代码块中的内容过于简单,状态切换消耗时间有可能比用户代码执行时间还要长,也就是同步资源锁定时间很短
自旋锁:若物理机有多个CPU,且并发低,同步资源锁定时间很短,能够让两个或以上的线程同时并行执行,就可以让后面那个请求锁的线程不进入阻塞状态,而是一直自旋检测持有锁的线程是否释放了锁,这个线程就可以不必阻塞,此后直接获取同步资源,从而避免切换线程的开销;但是若锁占用时间很长,那么自旋线程只会白白浪费CPU资源
阻塞锁:与自旋锁相反,在没拿到锁的情况下会直接将线程阻塞,直到被唤醒
下面代码只是为了模拟,大致思路是这样,后面可使用原子类实现
public class SpinLock {
private volatile Thread thread = null; //volatile保证线程可见性
//private AtomicReference<Thread> sign = new AtomicReference<>(); //引用类型原子类
public void lock() {
Thread current = Thread.currentThread();
do {
//if (sign.compareAndSet(null, current)) break; //判断并赋值的原子操作
if (thread == null) { //线程不安全,判断和赋值应该是原子操作,只是为了模拟,大致思路是这样
thread = current;
break;
}
System.out.println(current.getName() + "自旋获取失败,再次尝试");
} while (thread != current);
}
public void unlock() {
Thread current = Thread.currentThread();
//sign.compareAndSet(current, null); //判断并赋值的原子操作
if (thread == current) { //只有是当前线程上的锁才能解锁,线程不安全,判断和赋值应该是原子操作,只是为了模拟,大致思路是这样
thread = null;
}
}
public static void main(String[] args) throws InterruptedException {
SpinLock spinLock = new SpinLock();
Runnable runnable = () -> {
spinLock.lock();
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
try {
Thread.sleep(10); //等一会在释放自旋锁
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
spinLock.unlock();
}
};
new Thread(runnable).start();
new Thread(runnable).start();
}
}
可中断锁和不可中断锁
若一个线程正在等锁期间,可以响应中断信号,这种锁就是可中断锁
在Java中,synchronized就是不可中断锁,Lock就是可中断锁因为tryLock(time)和lockInterruptibly()
都能响应中断
public class SpinLock {
private volatile Thread thread = null; //volatile保证线程可见性
public void lock() {
Thread current = Thread.currentThread();
do {
if (thread == null) { //线程不安全,只是为了模拟,大致思路是这样
thread = current;
break;
}
//System.out.println(current.getName() + "自旋获取失败,再次尝试");
} while (thread != current);
}
public void unlock() {
thread = null;
}
public static void main(String[] args) throws InterruptedException {
SpinLock spinLock = new SpinLock();
Runnable runnable = () -> {
spinLock.lock();
System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
try {
Thread.sleep(10); //等一会在释放自旋锁
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放了自旋锁");
spinLock.unlock();
}
};
new Thread(runnable).start();
new Thread(runnable).start();
}
}
锁优化
JVM对锁的优化
- 自旋锁和自适应锁:在自旋时,若长时间获取不到锁,就会转换为阻塞锁
- 锁消除:在编译代码时,检测到根本不存在共享数据竞争,也就无需同步加锁了,但是一旦存在逸出,就意味着不能轻易将锁消除,无法知道外部是如何使用这些资源的
- 锁粗化:通常需要将同步块尽可能的小,但一系列连续对一个对象反复的加锁和解锁,这会导致不必要的新能损耗,这种情况JVM会将锁的范围扩大到整个操作序列
编写代码对锁的优化
- 缩小锁的范围,尽量使用同步代码块,而不是同步方法
- 尽量锁中不再包含锁,以免造成死锁
- 减少请求锁的次数(比如将所有需要锁的操作汇集到一起提交一个任务,批量操作)
- 选择合适的锁类型或合适的工具类
Comments NOTHING