文章目录
- 悲观/乐观锁
- 挂起等待锁/自旋锁
- 偏向锁
- 轻量级/重量级锁
- 锁升级
- CAS
- CAS引发的ABA问题
- 解决方案
- 原子类
- 公平/不公平锁
- 可重入锁
- ReentrantLock
- 读写锁
- Callable接口
这里的“悲观”“乐观”“挂起等待”“自旋”“轻量级”“重量级”“公平”“非公平”“可重入”仅代表某个锁的特性,不具体指某个锁。
悲观/乐观锁
悲观锁:总是在考虑最坏的情况,认为在拿数据时总是存在线程不安全问题,总是认为别人在同时修改这个数据,于是每次拿的时候给这个数据上锁,让其他线程拿不了直到锁释放。代表有synchronized、ReentrantLock。(重量级锁有悲观锁的影子!)
乐观:总是考虑最好的情况,认为总是不存在有线程在同时修改的情况,放心的去拿数据,但是乐观锁有CAS的机制,如果要进行更新操作,发现读取到更新之间有其他线程修改了这个数据,则会放弃修改,重新读取再修改直到成功。代表有原子类,原子类底层就是不断的CAS。(轻量级锁、偏向锁都有乐观锁的影子!)
挂起等待锁/自旋锁
自旋锁:线程未获得锁则不断尝试获取锁。可以及时获得锁,CPU空转(忙等),但缺点是可能会很消耗CPU资源,因此自旋到一定条件(设定时间、次数)就不自旋了。
while(抢锁失败){ 抢;if(!自旋条件) break;
}
挂起等待锁:线程未获得锁则进入阻塞状态,等待被唤醒。不会很消耗CPU资源,但缺点是等待周期可能会很长,不能及时获得锁。
偏向锁
当同步代码块只有一个线程运行时(可以理解为只有一个线程参与锁竞争),此时并不会给这个线程加锁,而是给这个线程一个标记ID(由JVM记录,这个机制也实现了Java的可重入锁,方便理解可参考我写的解决死锁段落),当下一次进入同步代码块时,根据这个ID判断是不是仍然还是这个线程,如果是则直接运行,否则偏向锁升级为轻量级锁,锁持有者为获取锁的线程,其他线程自旋。
上面的过程有点抽象,总结来说:只有一个线程执行同步代码块的时候,此刻加的锁为偏向锁,如果发生其他线程(同时竞争不激烈)来竞争锁,则锁升级为轻量级锁,如果竞争再激烈,则升级为重量级锁。
轻量级/重量级锁
轻量级锁:锁竞争不激烈的场景下,线程未获得锁则保持自旋(不阻塞等待一直尝试获取锁)。
重量级锁:锁竞争激烈的场景下,线程未竞到锁则进入阻塞状态,等待被唤醒,典型:synchronized
锁升级
Java的synchronized有锁升级的机制:
synchronized自适应进行升级的过程,保证了JVM不盲目加锁浪费资源,在锁竞争缓和的情况下线程不阻塞浪费时间,及时获取到锁,在锁竞争激烈的情况下,让线程阻塞减轻CPU负担。
CAS
CAS(Compare And Swap)。顾名思义先比较再交换,CAS涉及到三个数据,这里可以理解为value(修改前读取到的值;主内存中的共享变量)、exceptValue(上一次修改后保存的值;线程里的局部变量)、swapValue(要修改的值;线程里的局部变量)。定义为exceptValue是因为CAS期待exceptValue修改为swapValue。
CAS的工作机制:
- 读取内存值(value)。
- 比较 value 和exceptValue。
- 如果相等,则写入 swapValue,否则CAS失败。
这三步操作是不可分割的也就是说一次CAS是原子性的。
exceptValue的值在CAS成功后被更新为value,否则保持原值不变。
CAS引发的ABA问题
假设原值为A,要修改为B。如果存在多个线程要执行这个修改操作:
- 一个线程修改成功后,由于缓存一致性协议,value变化时,其他线程已经读取的value也会被强制刷新为最新值。多个线程进行CAS(A,A,B)时,一个成功后,其他的线程会CAS失败,不会对A重复CAS(A,A,B)。
- 但是CAS只检查值,不关心在修改这个值之前是否发生了A->B->…->A这种骚操作。由此引发了ABA问题。例如:
线程t1要将A修改为B,而线程t2要将A修改为B,再修改为A;
如果t2先执行完这两个CAS操作,t1由于缓存一致性协议会实时读取到最新值,所以t1在CAS前里面的A变为B再变为A,t1执行CAS,两次线程结束后最终结果为B。
虽然上面这ABA操作看起来没问题,但极端情况下却容易出问题:
我有200块存款,要取100块钱;
我来到ATM,插卡输密码,设置取100块再点击确定,恰好系统超时没有及时吐钱,我恼怒的再摁了一下,由此产生了两个线程t1,t2。两个线程都要CAS(200,200,100),正常情况下,总有一个成功而另一个失败,但是又恰好老妈打给我100块生活费,产生线程t3,在t2完成CAS后,t3又来一波CAS(100,100,200)加了100块,没有t3,t1应该是CAS(100,200,100)但现在成了CAS(200,200,100),于是ATM再吐100块。
这是不符合实际的,现实生活即便手痒快速摁几下,也不会重复响应。
解决方案
为此基于CAS只比较新旧值的特性引入了版本号,版本号是一个单调递增的常量,每次CAS就+1,这样即便A->B->A也能因为版本号而察觉到。
原子类
针对多线程操作共享变量例如例如变量自增这种而不发生线程不安全问题,可以使用原子类。原子类底层使用了CAS,可以保证变量操作的原子性,常见的原子类有AtomicInteger、AtomicIntegerArray、AtomicBoolean、AtomicLong、AtomicReference、AtomicStampedReference。举例AtomicInteger:
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;public class Main {public static void main(String[] args) {AtomicInteger i = new AtomicInteger();i.addAndGet(1); //对于int/Integer i,相当于i+=1System.out.println(i);i.decrementAndGet();//相当于--iSystem.out.println(i);i.getAndDecrement();//相当于i--System.out.println(i);i.incrementAndGet();//相当于++iSystem.out.println(i);i.getAndIncrement();//相当于i++System.out.println(i);}
}
公平/不公平锁
Java对公平锁的定义是先来后到,哪个线程先抢锁就哪个线程拿锁,所以非公平锁就是所有锁竞争者获得锁的机会均等(操作系统随机调度线程)。
可重入锁
一个线程执行到同步代码块给自己加了锁,在这个代码块里又给自己加了锁,造成了死锁,这个线程就自己阻塞自己,锁也释放不了。如果允许线程给自己多次加锁,但又不会发生死锁,同步代码块执行完后锁正常释放,那就是可重入锁。
学Java的同学不用担心死锁的问题,只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的。
ReentrantLock
和synchronized同级别,是在Java5以后引入的,相比于synchronized对锁的使用更灵活,也更复杂。以下是同synchronized的对比:
synchronized | ReentrantLock | |
---|---|---|
实现方式 | synchronzied是关键字,实现机制在JVM内部,需要手动解锁 | ReentrantLock是Java.util.concurrent.locks.ReentrantLock里的类,是在JVM外部实现的,而ReentrantLock需要手动unlock()解锁 |
锁竞争处理 | 线程锁竞争失败会一直阻塞 | 不会阻塞;可以通过trylock(TIME)/trylock()返回一个boolean值,如果是false代表竞争锁失败,调用者可根据这个判断编写竞争失败的代码逻辑 |
锁特性 | 非公平锁 | 默认非公平锁,可通过构造方法传入ture成为公平锁 |
等待和唤醒 | 可通过Object类的wait()、notify()进行等待和唤醒,但唤醒目标是随机的 | 可通过Condition类指定唤醒某个线程 |
常用方法lock()、trylock(Time)、unclock()。
import java.util.concurrent.locks.ReentrantLock;public class Main {public static void main(String[] args) {ReentrantLock locker=new ReentrantLock();Thread t1=new Thread(()->{for(int i=0; i<500; i++){try{locker.lock();System.out.println(Thread .currentThread().getName()+i);}finally{locker.unlock();//未防止忘记释放锁,将其放在finally里}}});t1.start();}
}
读写锁
多个线程对共享变量的读取是不会发生线程不安全的的,如果有写入的情况才会发生:
- 所有线程只读,线程安全
- 所有线程写入,线程不安全
- 有读有写,线程不安全
当有多个线程操作一个共享变量时,对其加锁且对它的读取是不互斥,但是写入时只能一个线程持有锁,达到:
- 读与读之间不互斥
- 所有线程写入,一个线程持有锁其他阻塞
- 有读有写,一个线程持有锁其他阻塞
于是我们引入了读写锁ReentrantReadWriteLock类,通过ReentrantReadWriteLock.readLock获取一个读锁ReadLock对象;通过ReentrantReadWriteLock.writeLock获取一个写锁WriteLock对象。
这两个对象使用lock()、tryLock()、**unlock()**来加锁释放锁。
import java.util.concurrent.locks.ReentrantReadWriteLock;public class Main {static int i=0;public static void main(String[] args) {ReentrantReadWriteLock locker=new ReentrantReadWriteLock();Thread t1=new Thread(()->{locker.readLock().lock();//readLock是ReentrantReadWriteLock类里RreadLock类型的字段System.out.println(i);locker.readLock().unlock();locker.writeLock().lock();//writeLock是ReentrantReadWriteLock类里WriteLock类型的字段i+=1;locker.writeLock().unlock();});t1.start();}
}
Callable接口
Callable类似于Runnable接口,里面定义了call()方法,同run()方法一样对任务进行了包装。
但不同的是Callable接口带有泛型,且call()方法带有返回值,且不可直接将实现了Callable接口的类对象作为参数直接传入Thread类的构造方法里,需要通过FutureTask类将任务提交到线程里。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;public class Main {public static void main(String[] args) throws InterruptedException, ExecutionException {//返回一个实现了Callable接口的匿名类对象Callable<Integer> callable = (() -> {int sum = 0;for (int i = 1; i <= 100; i++) {sum += i;}return sum;});FutureTask<Integer> futureTask = new FutureTask<>(callable);//FutureTask实现了RunnableFuture接口,RunnableFuture继承了Runnable接口Thread t = new Thread(futureTask);t.start();System.out.println(futureTask.get());}
}
如果要实现上面同样的功能,还需在Main里定义一个static字段类记录sum的值方便任务执行完后来确定任务效果。而通过call()带返回值的属性和new FutureTask.get()方法得到任务结果,将任务和线程分开,达到高内聚低耦合的目的。
完。