一、常见的锁策略
锁策略并不仅仅局限于Java,任何关于“锁”的问题,都有可能涉及到锁策略,虽然锁策略往往是给设计锁的人来作为参考的,但是我们了解之后使用起synchronized
来也会更加得心应手
(一)悲观锁和乐观锁
1.悲观锁
在悲观锁看来,它里面的数据会时常发生改变,预期锁冲突的概率很高,因此一个线程拿到锁后,其他线程必须等到锁释放之后才能竞争并访问代码块中的代码。因此我们说悲观锁做的工作多,付出成本高,比较低效
2.乐观锁
在乐观锁看来,它里面的数据不会经常改变,预期锁冲突的概率很低,因此它允许多个线程同时对数据进行变动,那这样说,锁不就失去作用了吗?并不是,它会对数据是否产生冲突进行检测,如果发现冲突了,就返回错误信息,让用户决定该怎么做。因此我们说,乐观锁做的工作更少,付出成本更低,比较高效
(二)读写锁和互斥锁
读写锁把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。相对于读写锁来说,互斥锁就是普通的锁,只有加锁和解锁操作。
我们需要注意的是:
- 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可
- 两个线程都要写一个数据,就有线程安全问题
- 一个线程读另外一个线程写,也有线程安全问题
因此读写锁就有了下面的特性:一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁
具体操作就是:
- 读加锁和读加锁之间, 不互斥
- 写加锁和写加锁之间, 互斥
- 读加锁和写加锁之间, 互斥
读锁:ReentrantReadWriteLock.ReadLock
类
写锁:ReentrantReadWriteLock.WriteLock
类
(三)重量级锁和轻量级锁
前提说明,互斥锁(Mutex Lock)位于操作系统底层,如果频繁的调用互斥锁进行加锁解锁,那么就会导致线程在用户态和内核态之间频繁切换,带来较大的性能损耗
1.重量级锁
加锁机制严重依赖操作系统提供的互斥锁(Mutex Lock)
- 频繁用户态内核态切换
- 容易引发线程调度
以上两个操作成本都比较高
2.轻量级锁
加锁机制尽量不使用mutex,而是尽量在用户态代码完成,实在不行,再使用mutex
- 少量的用户态内核态切换
- 不太容易引发线程调度
通常悲观锁都是重量级锁,乐观锁都是轻量级锁,但是不绝对
(四)挂起等待锁和自旋锁
- 挂起等待锁,往往就是通过内核态的一些机制来实现的,往往较重(重量级锁的一种典型实现)
- 自旋锁,往往就是通过用户态代码来实现的,往往较轻(轻量级锁的一种典型实现)
关于以上两种锁,我们可以笼统的认为挂起等待锁就是互斥锁,而自旋锁和互斥锁都是为了解决对某项资源的互斥使用。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,线程进入睡眠状态。但是自旋锁不会引起线程睡眠,而是让线程一直循环看锁是否释放。到时候锁释放后抢占互斥锁的被唤醒,抢占自旋锁的线程直接去抢占自旋锁不需要被唤醒。
因此才说挂起等待锁是重量级锁,因为它睡眠唤醒的操作就要涉及到内核态了,但是自旋锁只是在用户态让线程不断的观察锁的状态
(五)公平锁和非公平锁
比如说线程A先来,一段时间之后B来了,C在B之后来,不久A把锁释放
对于公平锁和非公平锁而言:
- 公平锁遵循“先来后到”,获得锁的顺序就是A->B->C
- 非公平锁不遵循“先来后到”,A把锁释放后,B和C等机会进行抢占,谁先抢到算谁的
对于操作系统来说,在相同优先级的情况下,本身就是随机调度的,因此基于操作系统实现的 mutex 互斥锁,就属于非公平锁,而如果想实现公平锁,反而代价会大一点,比如说起码要有个队列来排先来后到
(六)可重入锁和不可重入锁
可重入锁的概念与解释我们在多线程基础的时候介绍synchronized时,就已经详细解释了,总的来说,可重入锁就是允许一个线程连续多次获取同一把锁(可以嵌套着使用同一把锁),不可重入锁则表示一个线程只能获取一把锁一次,想要再次获取,必须先释放才能再获取
Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized 关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁
二、CAS
(一)概念
CAS:全称Compare and swap
一个CAS操作涉及到以下操作:
- 比较 A 与 V 是否相等(比较)
- 如果相等,将B写入V(交换)
- 返回操作是否成功
硬件予以了支持,软件层面才能做到
伪代码说明
//如果A和V表示的是同一块空间的值,那么就可以表示原空间的值不变,就把新值赋值给旧值
while(flg == true) {
if(A == V) {
V = B;
flg = false;
}
}
最重要的是,CPU提供了一个单独的CAS指令,换句话说,CAS是原子性的操作,上述伪代码的操作是线程不安全的,但是一条指令是线程安全的,因此CAS最大的意义,就是为我们写线程安全的代码提供了一个新的思路和方向
(二)基于 CAS 实现的操作
基于CAS实现原子类
标准库中提供了java.util.concurrent.atomic
包,里面的类都是基于这种方式来实现的
代码示例:
public static void main(String[] args) throws InterruptedException {
//AtomicInteger就是一个原子类
AtomicInteger num = new AtomicInteger();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 500; i++) {
//getAndIncrement方法相当于num++操作
num.getAndIncrement();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 500; i++) {
num.getAndIncrement();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(num);
}
(三)CAS的 ABA 问题
1.概念
CAS的操作中,第一步是比较,第二步是交换,如果有一个地址,在获取到这个地址的数值之前,数值是1,在比较前数值被其他线程修改,变成0,又变成了1,此时进行比较,1还是1,但已经不是原来的那个1,但是CAS还把他当作原来的那个1来对待,就可能引发一些问题
2.ABA问题举例说明
假如小明很喜欢小美,并且小明知道小美一直没有男朋友,小美跟小明说,如果三个月之后,我还是单身,我们就在一起。小明答应了,然后这三个月小明和小美再没见过,在这期间,小美换了五个男朋友,等到三个月一到,小美恢复单身,他俩在一起了,虽然小美看起来没什么变化,但是其实她已经不是原来的她了,假如小明有洁癖,他自己是初恋,他也只想跟对方是初恋的情况下谈恋爱,现在暂时没事,但是说不准什么时候小明就知道了,这颗雷就会引爆
3.解决方案
给要修改的值,引入版本号,每次值改变之后,版本号都会加1,在CAS比较数据当前值和旧值的同时,也要比较版本号是否相同
- CAS操作在读取旧值的同时,也要读取版本号
- 到了要比较修改的时候
- 如果当前版本号和读到的版本号相同,就修改数据,并把版本号 + 1
- 如果当前版本号高于读到的版本号,就操作失败(版本号不同,认为数据已经被修改过了)
三、Synchronized 中的优化机制(jdk1.8版本机制)
(一)Synchronized锁的类型
- 即是一个乐观锁,也是一个悲观锁
- 不是读写锁
- 既是一个轻量级锁,也是一个重量级锁
- 轻量级锁的部分是自旋锁的操作,重量级锁的部分是挂起等待锁的操作
- 是非公平锁
- 是可重入锁
(二)Synchronized的优化手段
1.锁膨胀/锁升级
JVM将 Synchronized 锁分为无锁,偏向锁,轻量级锁,重量级锁状态,根据情况,依次升级
- 无锁,即没有加锁的情况
- 偏向锁,有一个线程来加锁,但
synchronized
不会真的加锁,只是做了一个标记 - 轻量级锁(自旋锁),当存在其他线程对这个锁进行竞争时,
Synchronized
就会转为自旋锁 - 重量级锁,当线程很多,竞争很激烈的时候,此时再使用自旋锁对CPU资源浪费就太大了,因此换成重量级锁,即让等待的线程直接挂起等待
2.锁粗化
粗化对应的就是细化,这里的粗和细是指“锁的粒度”
- 细指的是一个锁里面代码段少,此时加锁解锁的操作就会更加频繁,但是优点是并发程度高了
- 粗是指一个锁里面代码段较多,加锁解锁的频率降低,缺点就是并发程度不高
由此看来,锁粗和锁细各有优缺点
实际开发中,使用细度锁,是期望释放锁的时候其他线程能够使用锁,但是实际上可能并没有其他线程来抢占这个锁,这种情况JVM就会自动把锁粗化,避免频繁申请释放锁,提高运行效率。但是如果锁比较粗,一般不会进行这种锁优化
3.锁消除
当一段代码不必加锁(比如单线程环境)而我们给他加了锁,那么就会触发“锁消除”,即把该锁取消掉,是否该取消,由编译器 + JVM来判断
四、JUC(java.util.concurrent
)
JUC是处理并发操作的包
(一)Callable接口
1.用法
Callable是一个interface,和Runnable类似,也描述一个任务,但是具有返回值,也是一种创建线程的方式
代码示例(计算1~1000的和并返回):
public static void main(String[] args) {
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();
try {
System.out.println(task.get());
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
2. Callable的理解
1. Callable 和 Runnable 相对,都是描述一个 “任务”。Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务.
2. Callable 通常需要搭配 FutureTask 来使用。FutureTask 用来保存 Callable 的返回结果。因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定。FutureTask就可以负责这个等待结果出来的工作
3. FutureTask的理解
类似一个转接器,FutureTask
实现了Runnable
接口,并且内部有一个Callable属性,既然Callable
本身无法作为参数传递给Thread,那么就借助FutureTask,实现run方法,在run方法内部执行Callable
中的call
方法,并保存返回值,最后通过get()
来得到返回值
由于call
执行也需要时间,因此get
可能会发生阻塞,等待结果的产生
(二)ReentrantLock
显而易见,它是一个可重入锁,另外,他还是一个互斥锁,,和synchronized
类似,都是为了实现互斥,并且能保证线程安全
1.用法
- lock():加锁,如果获取不到锁就死等
- trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁
- unlock():解锁
代码示例:
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
}finally {
lock.unlock();
}
}
如上图,可以看出ReentrantLock
的加锁解锁操作是分开的,这样其实不如合起来,因为很可能代码写的多了会忘记释放锁,因此需要将锁的释放放在finally,这样代码出现问题时,也可以将锁释放,但是写起来比较麻烦
2.ReentrantLock 和 synchronized 的区别
- synchronized是一个关键字(逻辑由C++实现),ReentrantLock是一个标准库的类(逻辑由Java代码实现)
- synchronized不需要手动释放锁,执行完代码块,锁自动释放,ReentrantLock必须手动释放锁
- synchronized如果竞争锁失败,就会阻塞等待,但是ReentrantLock除了阻塞等待操作,还可以指定最长等待时间,失败了直接返回,不必一直死等
- synchronized是一个非公平锁,ReentrantLock提供了非公平和公平锁两个选择,如果想要使用公平锁,那么在创建锁时需要传递参数true
- synchronized的等待唤醒机制是
wait notify
,功能比较有限,每次唤醒只是唤醒随机的一个线程;ReentrantLock的等待唤醒机制,是Condition
类,功能更加丰富,可以实现指定线程的唤醒操作,这点为第四点的公平锁实现了前提条件
(三)信号量-Semaphore
信号量,用来表示 “可用资源的个数”。本质上就是一个计数器
申请一个可用资源,可用资源 - 1(信号量的 P 操作),释放一个可用资源,可用资源 + 1,(信号量的 V 操作),如果计数器的值已经为0,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源
Semaphore的PV操作都是原子的,可以在多线程环境下直接使用。
信号量可以看作是广义锁,我们理解的锁,是被一个线程获取到之后其他线程就无法获取了,这样的锁又可以被称为“二元信号量”,可用资源就一个,计数器的取值,非0即1
代码示例:
public static void main(String[] args) throws InterruptedException {
//表示可用资源数为4
Semaphore semaphore = new Semaphore(4);
//申请一个资源,可以传参申请多个
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
semaphore.acquire();
System.out.println("申请成功");
//释放资源
semaphore.release();
}
上述代码中,当申请成功四次之后,可用资源数为0,因此就会阻塞,也就不会执行到下面的代码了
(四)CountDownLatch
CountDownLatch
是一个线程同步类,能同时等待 N 个任务执行结束
代码示例:
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + "执行结束");
//这步操作才是告诉latch,一个线程结束
latch.countDown();
});
t.start();
}
//latch中存在类似一个计数器的属性,一个信号发过来,计数器值就减 1
//如果数目没到之前参数的个数,那么就阻塞等待
latch.await();
System.out.println("全部执行完毕");
}
运行结果:
五、线程安全的集合类
我们之前学习数据结构时学习到的集合类,大部分都是线程不安全的,有些是线程安全的,但是不建议使用,,因为加锁太多,执行效率太低。比如:
Vector, Stakc,HashTable
(一)多线程环境使用 ArrayList
-
自己使用同步机制(synchronized 或者 ReentrantLock)
-
Collections.synchronizedList(new ArrayList);
synchronizedList是标准库提供的一个基于synchronized进行线程同步的List
synchronizedList的关键操作都带有synchronized -
使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器
优点:
在读多写少的场景下,性能很高,不用加锁
缺点:- 占用内存较多,不适合修改数据量很大的内容,一般可用于更新配置数据
- 新写的内存不能被第一时间读取到
(二)多线程环境下使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列 - LinkedBlockingQueue
基于链表实现的阻塞队列 - PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列 - TransferQueue
最多只包含一个元素的阻塞队列
(三)多线程环境使用哈希表(重点)
HashMap不是线程安全的,在多线程环境下使用哈希表可以使用:
- HashTable
- ConcurrentHashMap
1. HashTable
HashTable只是简单把关键方法,比如get,put
加上了synchronized,这相当于直接针对HashTable对象本身加锁,这样的操作会造成极大的锁冲突
- 多个线程访问同一个HashTable会冲突
- size属性也是通过HashTable来控制同步
- 一旦触发扩容,就由当前线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率极低
我们知道,Java处理哈希冲突的操作是数组 + 链表,因此对于每一个链表来说,它们相互之间是独立的,如果两个操作针对的是不同的链表,那么完全可以对不同链表分开加锁,既保证安全,又大大降低了锁冲突的效率
2.ConcurrentHashMap
ConcurrentHashMap
相比于Hashtable
做出了一系列的改进和优化,以Java1.8为例
- 把锁加到每个链表的头节点上(锁桶)
- 只是针对写操作加锁了,读操作没有加锁只使用
- 更广泛的使用CAS,比如维护size
- 如果扩容,每次操作只搬运一点,通过多次操作完成整个搬运过程,这样做的话,就需要同时维护一个新的HashMap和旧的HashMap,查找的时候既查新的,也查旧的,插入的时候只插新的,直到搬运完毕,再销毁旧的