锁策略不仅仅是局限于 Java . 任何和 “锁” 相关的话题, 都可能会涉及到以下内容. 这
些特性主要是给锁的实现者来参考的.
悲观锁:预期锁冲突的概率很高;
乐观锁:预期锁冲突的概率很低;
悲观锁,做的工作更多,付出的成本更多,更低效;
乐观锁,做的工作更少,付出的成本更低,更高效;
悲观锁 ,乐观锁是处理锁冲突的态度(原因)。
对于普通的互斥锁,只有两个操作,加锁和解锁。
只要两个线程针对同一个对象加锁,就会产生互斥
对于读写锁来说,分成三个操作:
加读锁:如果代码只进行读操作,就加读锁;
加写锁:如果代码中进行了修改操作,就写加锁;
解锁
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的,比如数据库索引).
读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock
类, 实现了读写锁.
ReentrantReadWriteLock.ReadLock
类表示一个读锁. 这个对象提供了 lock / unlock
方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock
类表示一个写锁. 这个对象也提供了 lock / unlock
方法进行加锁解锁.
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
重量级锁:做了更多的事情,开销更大;大量的内核态用户态切换,很容易引发线程的调度。
轻量级锁:做的事情更少,开销更小;少量的内核态用户态切换,不太容易引发线程调度。
也可以认为,通常情况下悲观锁一般都是重量级锁.乐观锁─般都是轻量级锁.(但是不绝对)。
在使用的锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统提供的mutex
接口),此时一般认为这是重量级锁.(操作系统的锁会在内核中做很多的事情,比如让线程阻塞等待…)。
如果锁是纯用户态实现的, 此时一般认为这是轻量级锁(用户态的代码更可控,也更高效)。
挂起等待锁:往往就是通过内核的一些机制来实现的.往往较重.[重量级锁的一种典型实现];
自旋锁:往往就是通过用户态代码来实现的.往往较轻.[轻量级锁的一种典型实现];
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题.如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会
在极短的时间内到来.一旦锁被其他线程释放, 就能第一时间获取到锁。
自旋锁是一种典型的 轻量级锁 的实现方式.
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
公平锁:多个线程在等待—把锁的时候 ,谁是先来的,谁就能先获取到这个锁(遵守先来后到); B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁:多个线程在等待一把锁的时候,不遵守先来后到 (每个等待的线程获取到锁的概率都是均等的)
mutex
这个锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构,来记录线程们的先后顺序.一个线程针对一把锁,连续加锁两次,如果会死锁,就是不可重入锁;如果不会死锁,就是可重入锁。
CAS(Compare and swap
):比较并交换.
拿着寄存器/某个内存中的值和另外一个内存的值进行比较.如果值相同了,就把另一个寄存器/内存的值和当前的这个内存进行交换.
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功
伪代码:
下面写的代码不是原子(既有读又有写)的, 真实的 CAS
是一个原子的硬件指令完成的. 这个伪代码只是辅助理解CAS
的工作流程。
address:待比较的内存地址.
expectValue:预期内存中的值
swapValue:希望把内存的值改成这个新的值
boolean CAS(address, expectValue, swapValue) {
// &---取出内存中的值看一下结果
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
此处所谓的CAS指的是,CPU提供了一个单独的CAS指令,通过这一条指令,就完成上述伪代码描述的过程。CAS最大的意义,就是让我们写这种多线程安全的代码,提供了一个新的思路和方向.(就和锁不一样了)
CAS
能够实现"原子类"标准库中提供了 java.util.concurrent.atomic
包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger
类. 其中的 getAndIncrement
相当于 i++
操作.
(Java标准库里提供了一组原子类,针对所常用多一些的int, long, int array...
进行了封装,可以基于CAS
的方式进行修改,并且线程安全)
例如基于 AtomicInteger
实现多线程自增同一个变量:
import java.util.concurrent.atomic.AtomicInteger;
public class Test04 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num = new AtomicInteger(0);
Thread t1 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
num.incrementAndGet();
}
});
Thread t2 = new Thread(()->{
for (int i = 0; i < 5000; i++) {
num.incrementAndGet();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
// 通过 get 方法得到 原子类 内部的数值.
System.out.println(num.get());
}
}
这个代码里面不存在线程安全问题.基于CAS
实现的++
操作.
这里面就可以保证既能够线程安全,又能够比 synchronized
高效.synchronized
会涉及到锁的竞争.两个线程要相互等待;CAS
不涉及到线程阻塞等待.
伪代码实现:
class AtomicInteger {
//存储原始数据
private int value;
public int getAndIncrement() {
//这里注意:看起来是一个oldvalue变量实际实现中可能是直接用一个
//寄存器来存的(伪代码中不好表示寄存器)
//赋值操作就相当于把数据从内存读到寄存器中. (load)
int oldValue = value;
//判定一下,当前内存的值是不是和刚才寄存器里取到的值一致,如果判定成功,
//就把value设为oldValue+1.返回true,循环结束.
//如果判定失败,就啥都不做,返回false,继续下次循环.
//(下次循环,先重新读—下value,然后再来cas)
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
CAS
能够实现"自旋锁"基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码:
public class SpinLock {
//记录下当前锁被哪个线程持有了,为null表示当前未加锁
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
和刚才的原子类类似,也是通过一个循环来实现的.循环里面调用CAS.
CAS
会比较当前的owner
值是否是null
,如果是 null
就改成当前线程.意思就是当前线程拿到了锁;如果不是 null就返回false,进入下次循环.下次循环仍然是进行CAS
操作;如果当前这个锁一直被别人持有,当前尝试加锁的线程就会在这个while
的地方快速反复的进行循环,即为自旋 (忙等)。
自旋锁是一个轻量级锁,也可以视为是一个乐观锁。当前这把锁虽然没能立即拿到,预期很快就能拿到. (假设锁冲突不激烈),短暂的自旋几次,浪费点CPU,问题都不大.好处就是只要这边锁一释放,就能立即的拿到锁。
CAS
中的关键,是先比较,再交换,比较其实是在比较当前值和旧值是不是相同,如果两个值相同,就视为是中间没有发生过改变。但是当前值和旧值之间也有可能变了,但是又变回来了,这样的漏洞,在大多数情况下,其实没啥影响,但是,极端情况下也会引起bug。
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况。
比如转账的例子:
假设 小张 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
异常的过程:
给要修改的数据引入版本号. 在 CAS
比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。
当引入版本号之后, t2再尝试进行这里的比较版本操作就发现版本的旧值和当前值并不匹配.因此就放弃进行修改。如果直接拿变量本身进行判定,因为变量的值有加有减,就容易出现ABA的情况.现在是拿版本号来进行判定,要求版本号只能增加,这个时候就不会有ABA问题了 。
这种基于版本号的方式来进行多线程数据的控制,也是一种乐观锁的典型实现。
synchronized
锁特点JVM
将 synchronized
锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销);如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.也就是所谓的 “自适应”.
如果竞争进一步加剧,就会进入重量级锁状态。
此处的重量级锁就是指用到内核提供的 mutex
.
体现了synchronized
能够"自适应"这样的能力.
此处的粗细指的是“锁的粒度"(加锁代码涉及到的范围:加锁代码的范围越大,认为锁的粒度越粗,范围越小,则认为粒度越细)。
锁的粒度是粗还是细?其各有各的好处:
如果锁粒度比较细,多个线程之间的并发性就更高;
如果锁粒度比较粗,加锁解锁的开销就更小;
编译器就会有一个优化:如果某个地方的代码锁的粒度太细了,就会自动判定进行粗化;
如果两次加锁之间的间隔较大(中间隔的代码多),一般不会进行这种优化.如果加锁之间间隔比较小(中间隔的代码少),就很可能触发这个优化。
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
有些代码,明明不用加锁,结果你给加上锁了.编译器就会发现这个加锁好像没啥必要,就直接把锁给去掉了。
有的时候加锁操作并不是很明显,稍不留神就做出了这种错误的决定。
StringBuffer
, Vector
…在标准库中进行了加锁操作,在单线程中用到了上述的类,就是单线程进行了加锁解锁。
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销。
Callable是一个interface, 也是一种创建线程的方式。
Runnable
不太适合于让线程计算出一个结果。
例如,像创建一个线程,让这个线程计算1+2+ 3 + .... + 1000
,如果基于Runnable
来实现,就会比较麻烦;Callable
就是要解决Runnable
不方便返回结果这个问题的。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Test02 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//Callable描述一个任务
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
//辅助类
FutureTask<Integer> task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
System.out.println(task.get());//500500
}
}
FutureTask
来使用. FutureTask
用来保存 Callable
的返回结果.因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.FutureTask
就可以负责这个等待结果出来的工作.(当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是FutureTask
. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没).可重入互斥锁. 和 synchronized
定位类似, 都是用来实现互斥效果, 保证线程安全。
import java.util.concurrent.locks.ReentrantLock;
public class Demo29 {
public static void main(String[] args) {
ReentrantLock locker = new ReentrantLock();
// 加锁
locker.lock();
// 抛出异常了. 就容易导致 unlock 执行不到~~
// 解锁
locker.unlock();
}
}
lock()
: 加锁, 如果获取不到锁就死等.trylock(超时时间)
: 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.unlock()
: 解锁这里把加锁和解锁两个操作分开了,这种分开的做法不太好,很容易遗漏unlock
(容易出现死锁)当多个线程竞争同一个锁的时候就会阻塞。
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
所以这里用finally来解决这种情况,保证不管是否异常都能执行到unlock ,但这么写比较麻烦。
1.synchronized
是一个关键字(背后的逻辑是VM内部实现的,C++),ReentrantLock
是一个标准库中的类(背后的逻辑是Java代码写的);
2. synchronized
不需要手动释放锁,出了代码块,锁自然释放.ReentrantLock
必须要手动释放锁,要谨防忘记释放.
3.synchronized
如果竞争锁的时候失败,就会阻塞等待,但是ReentrantLock
除了阻塞等待外, 可以通过 trylock
的方式等待一段时间就放弃;
4.synchronized
是一个非公平锁.ReentrantLock
提供了非公平和公平锁两个版本,在构造方法中,通过参数来指定当前是公平锁还是非公平锁;
5.基于synchronized
衍生出来的等待机制是wait notify
,每次唤醒的是一个随机等待的线程;基于ReentrantLock
衍生出来的等待机制是Condition类(条件变量),可以更精确控制唤醒某个指定的线程。
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器。
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
代码示例:
import java.util.concurrent.Semaphore;
public class Test05 {
public static void main(String[] args) throws InterruptedException {
Semaphore s = new Semaphore(4);
//P操作
s.acquire();
System.out.println("申请资源成功!");
s.acquire();
System.out.println("申请资源成功!");
s.acquire();
System.out.println("申请资源成功!");
s.acquire();
System.out.println("申请资源成功!");
s.acquire();
System.out.println("申请资源成功!");
//V操作 释放资源
// s.release();
}
}
同时等待 N 个任务执行结束.
countDown
给每个线程里面去调用,就表示到达终点了。await
是给等待线程去调用.当所有的任务都到达终点了, await
就从阻塞中返回,就表示任务完成。
就好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩.
import java.util.concurrent.CountDownLatch;
public class Test06 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(()->{
try {
Thread.sleep(3000);
latch.countDown();
System.out.println(Thread.currentThread().getName() + "到达终点!");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
latch.await();
System.out.println("比赛结束!");
}
}
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个:
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
例子在二(2.2 -> 1)
中。
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
自己使用同步机制 (synchronized 或者 ReentrantLock)
Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
CopyOnWrite容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本,不会说出现读到一个"修改了一半"的中间状态。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
只是简单的把关键方法加上了 synchronized
关键字。
public synchronized V put(K key,V value){
public synchronized V get(Object key){
这相当于直接针对 Hashtable
对象本身加锁.
相比于 Hashtable 做出了一系列的改进和优化. 以 Java1.8 为例:
操作元素的时候,是针对这个元素所在的链表的头结点来加锁的.
如果你两个线程操作是针对两个不同的链表上的元素,没有线程安全问题,其实不必加锁;
由于hash表中,链表的数目非常多,每个链表的长度是相对短的,因此就可以保证锁冲突的概率就非常小了.
ConcurrentHashMap
减少了锁冲突,就让锁加到每个链表的头结点上(锁桶);ConcurrentHashMap
只是针对写操作加锁了.读操作没加锁.而只是使用;ConcurrentHashMap
中更广泛的使用CAS
,进一步提高效率(比如维护size操作);ConcurrentHashMap
针对扩容,进行了巧妙的化整为零;对于HashTable
来说只要你这次put触发了扩容就一口气搬运完.会导致这次 put非常卡顿.对于ConcurrentHashMap
,每次操作只搬运一点点.通过多次操作完成整个搬运的过程.同时维护一个新的 HashMap
和一个旧的.查找的时候既需要查旧的也要查新的.插入的时候只插入新的,直到搬运完毕再销毁旧的.