一、常见的锁策略
1.1读写锁
多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需 要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生。
读写锁(readers-writer lock),看英文可以顾名思义,在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥。
一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.
两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
两个线程都要写一个数据, 有线程安全问题.
一个线程读另外一个线程写, 也有线程安全问题.
读写锁就是把读操作和写操作区分对待.
Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.(reentrant-可重入的)
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行 加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
其中,
读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥.
读写锁特别适合于 “频繁读, 不频繁写” 的场景中. (这样的场景其实也是非常广泛存在的).
1.2公平锁 vs 非公平锁
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后 C 也尝试获取锁, C 也获取失败, 也阻塞等待.当线程 A 释放锁的时候, 会发生啥呢?
公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.
操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要 想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
公平锁和非公平锁没有好坏之分, 关键还是看适用场景
synchronized 是非公平锁.
1.3可重入锁 vs 不可重入锁
可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入 锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized关键字锁都是可重入的。
而 Linux 系统提供的 mutex 是不可重入锁.
synchronized 是可重入锁
那么什么时候会是死锁呢?面试官问你:举个例子说一下死锁吧!
答:你发我offer我就和你说一说死锁。(可以去看java初阶部分,那里我详细说明了自己把自己锁死是怎么回事!)
1.4乐观锁 vs 悲观锁
悲观锁:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并 发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
举个栗子: 同学 A 和 同学 B 想请教老师一个问题.
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师 你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.
这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.
Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突. 我们可以引入一个 “版本号” 来解决。
假设我们需要多线程修改 “用户账户余额”.
设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 “提交版本必须大于记录
当前版本才能执行更新余额”
- 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1, balance=100 )。
2) 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 (100-20 )
3) 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中;
- 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不 满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.
1.5重量级锁 vs 轻量级锁
锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.
- CPU 提供了 “原子操作指令”.
- 操作系统基于CPU的原子指令实现了mutex互斥锁
- JVM基于操作系统提供的互斥锁,实现了synchronized和reenttrantlock等关键字和类
重量级锁: 加锁机制重度依赖了 OS 提供了 mutex
大量的内核态用户态切换,很容易引发线程的调度
这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.
轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex.,少量的内核态用户态切换.
不太容易引发线程调度.
理解用户态 vs 内核态
想象去银行办业务.在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的. 如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.
synchronized 开始是一个轻量级锁. 如果锁冲突比较严重, 就会变成重量级锁.
1.6自旋锁(Spin Lock)与挂起等待锁
按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个 时候就可以使用自旋锁来处理这样的问题.
自旋锁伪代码
while (抢锁(lock) == 失败) {}
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.一旦锁被其他线程释放, 就能第一时间获取到锁.
理解自旋锁 vs 挂起等待锁
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意,这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.
自旋锁是一种典型的 轻量级锁 的实现方式.
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是 不消耗 CPU 的).
synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.
1.7Synchronized所用到的锁策略
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略,实现重量级锁大概率是使用挂起等待锁
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
二、CAS
现在寄存器里面有两个值旧的预期值A,需要修改的新值B。而内存中有一个值是原数据V。我们要做以下操作
- 比较 A 与 V 是否相等。(比较)
- 如果比较相等,将 B 写入 V。(交换)
- 返回操作是否成功。
此处最特别的地方,上述CAS的过程不是通过一段代码实现(不是通过多个指令完成),而是一条指令完成,这就保证了操作的原子性。,这样就可以在一定程度上规避了线程安全问题。
所以这里给了我们另外一种解决线程安全问题的途径,我们之前都是从代码层面用加锁和解锁的方式保证操作的原子性,但是现在我们还从指令的原子性入手。
所以CAS可以理解为CPU给我们提供了原生指令,这个指令可以帮助我们解决线程安全问题。
2.1CAS的应用场景
1.基于CAS实现原子类
标准库准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作,incrementAndGet相当于++i
所以我们的之前线程不安全的自增程序可以这样修改
package thread;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo30 {
public static void main(String[] args) {
//这些原子类,就是基于CSA实现了原子性的自增、自减,此时这些类的操作不需要加锁,也是线程安全的
AtomicInteger count = new AtomicInteger(0);
Thread thread1 = new Thread(()->{
for(int i = 0;i<500;i++) {
// count++;//count是AtomicInteger类型,自增就不再使用++了
count.getAndIncrement();//相当于count++
// count.incrementAndGet();//相当于++count
}
});
Thread thread2 = new Thread(()->{
for(int i = 0;i<500;i++) {
count.getAndIncrement();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count.get());
}
}
AtomicInteger.getAndIncrement 的过程详解
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
这里是伪代码,这里的oldValue 等变量应该理解为寄存器里面的值,value代表内存中的值,另外两个代表寄存器里的值。
假设两个线程同时调用 getAndIncrement
- 两个线程都读取 value 的值到 oldValue 中. (oldValue 是一个局部变量, 在栈上. 每个线程有自己的栈)
-
线程1 先执行 CAS 操作. 由于 oldValue 和 value 的值相同, 直接进行对 value 赋值.
注意:
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的.
-
线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, (要注意第一步说了是线程1和2一起读的value,所以这里oldvalue值为0,然后cas是value的值为1)不能进行赋值. 因此需要 进入循环.在循环里重新读取 value 的值赋给 oldValue
-
线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作.
-
线程1 和 线程2 返回各自的 oldValue 的值即可.
通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作.
2.2 实现自旋锁
基于 CAS 实现更灵活的锁, 获取到更多的控制权.
自旋锁伪代码
public class SpinLock {
private Thread owner = null;
public void lock(){
// 通过 CAS 看当前锁是否被某个线程持有.
// 如果这个锁已经被别的线程持有, 那么就自旋等待.
// 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程.
while(!CAS(this.owner, null, Thread.currentThread())){
}
}
public void unlock (){
this.owner = null;
}
}
2.3CAS的典型问题
CAS运行的核心就在于检查value值和oldValue值是否一致,如果一致,就视作value中途么有被修改,所以进行下一步操作是没有问题的。
但是这里就有一个问题了,如果是vlue值被修改了,然后又被修改回原来的值了呢?
这就是ABA的问题
针对上述问题,很好解决,引入版本号就可以,之前是以内存中的值是不是变动了来确定内存中的值有没有被修改,但是这样会有回退的情况,但是版本号只会增不会减,也就避免了上述的ABA情况了。
在 Java 标准库中提供了 AtomicStampedReference 类. 这个类可以对某个类进行包装, 在内部就提 供了上面描述的版本管理功能.
三、Synchronized原理
3.1基本特点
结合上面的锁策略, 我们就可以总结出, Synchronized 具有以下特性(只考虑 JDK 1.8):
- 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
- 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
- 实现轻量级锁的时候大概率用到的自旋锁策略
- 是一种不公平锁
- 是一种可重入锁
- 不是读写锁
3.2加锁工作过程(锁升级)
JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
当第一个线程访问的时候,Sychronized先进入偏向锁的形式
但是,实际上偏向锁并不是锁,
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
当sycchronized发生锁竞争的时候,就会从偏向锁升级成轻量级锁,此时sysycchronized就是采用类似CAS的方式通过自旋的方式来加锁。所以此时这个线程还是大部分时间占用着CPU的,如果此时锁竞争比较小,自旋对于系统而言是合理的,但是一旦锁竞争开始逐步加大,这个线程开始长时间竞争不到锁,那就不能保持自旋状态了,需要变为重量级锁,也就是以挂起等待的方式去实现,此时就要释放CPU资源了。
重量级锁是基于操作系统API实现的,也就是前面说过的JVM通过调用Linux(以linux系统为例)系统的原生API mutex进行加锁和解锁,而这个API底层的实现依靠的就CPU线程的调度。此时如果这个线程竞争不到锁,就会放到阻塞对列中
四、JVM中的其他的锁优化
4.1消除锁
这是编译器自动的优化的,程序员是感知不到的,就是如果JVM判断当前情况下没有加锁的需要,那么即使程序员在代码里有加锁的操作,JVM也会对这个锁进行优化。
比如说我们都知道StringBuffer是线程安全的,本质就是因为它里面的每个操作都有sychronize修饰,也就是都有加锁操作,但是如果我只是在单线程的情况下去使用StringBuffer,那么实际JVM在背后是消除了这个锁的。
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
4.2锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
首先说一个概念叫做锁的粒度
Sychronized中所包含的代码越多,粒度就会越粗,反之则越细。
通常情况下锁的粒度是细一点比较好的,锁越粗就代表不能并发执行的代码很多,所以锁细一点代码并发程度理论上会高一点。
但是有的情况反而会对锁进行粗化
比如说
在一段时间内,一个锁的加锁解锁间隔时间很短,那么不如直接用一把大锁将这三段代码包含进去。这就是锁粗化。
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
五、JUC常见类
JUC 为 java.until.concurrent,这个包里存的就是并发编程的相关工具类
5.1Callable接口
- Callable接口与Runable接口的作用的是一样的,但是Callable是带泛型型参数的,重写里面的call方法(与run方法类似)返回的就是泛型类型的数据。
- callable是不能直接作为new thread()的参数的,这一点与runable不同。而是要在封装在futureTask类中,在传入new thread中。
比如说现在我想要从1+2+3 。。。。。。如果用runable我只能使用一个类进行实例化,采用引用的方式在不同在线程中记录加过的值
package thread;
class Counter3{
int sum ;
}
public class ThreadDemo31 {
public static void main(String[] args) {
Object locker = new Object();
Counter3 counter = new Counter3();
Thread thread1 = new Thread(){
@Override
public void run() {
counter.sum = 0;
synchronized (locker){
for(int i = 1; i <= 4; i++) {
counter.sum= counter.sum+i;
}
locker.notify();
}
}
};
thread1.start();
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter.sum);
}
}
}
但是如果使用callable就不一样了
- 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.把 callable 实例使用 FutureTask 包装一下.
- 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的 call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结 果.
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;
public class ThreadDemo32 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum= 0;
for (int i = 1; i <= 4; i++) {
sum = sum+i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<Integer>(callable);
Thread thread1 = new Thread(futureTask);
thread1.start();
System.out.println(futureTask.get());
}
}
5.2 RentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.ReentrantLock 也是可重入锁. “Reentrant” 这个单词的原意就是 “可重入”
ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁
package thread;
import java.util.concurrent.locks.ReentrantLock;
public class TreadDemo33 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();//解锁
//需要加锁的代码
reentrantLock.unlock();//解锁
}
}
与Sychronized第一个不同就在于ReentrantLock的加锁解锁是依据代码的顺序执行,而Sychronized则是以代码块形式。这有好有坏。
好处在于ReentratLock比Sychronized更加灵活(更灵活一方卖弄体现在这里另一方ReentratLock可以自主决定什么时候解锁,而Sy是一直死等的,如果到代码块结束才会解锁)。
那么这样的坏处也很明显,就是解锁可能存在根本执行不到的情况。
不如说要加锁的代码中有return,有循环等都可能导致代码执行中途就停止执行了,导致锁并没有释放。
所以使用ReentrantLock必须搭配try finally使用,把解锁操作放入finally中,才能保证无论如何一定会被解锁
package thread;
import java.util.concurrent.locks.ReentrantLock;
public class TreadDemo33 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
try{
reentrantLock.lock();//解锁
//需要加锁的代码
}
finally {
reentrantLock.unlock();//解锁
}
}
}
此外ReentrantLock比Sychronized更有优势的地方在于
- ReentrantLock构造方法中提供了实现公平锁的版本,所以ReentrantLock可以实现公平锁,只需要输入true即可
ReentrantLock reentrantLock = new ReentrantLock(true);
- 对于Sychronized而言加不上锁只会死等,但是ReentrantLock并不是,提供了trylock方法,而trylock也有有参数版本,和无参数版本无参版本表示能加锁就加锁,不能加锁就直接放弃,不执行了。
而有参版本表示在规定时间内尝试加锁,如果得到规定时间还是没有获取到所,就放弃。
trylock本身也有返回值,我们可以通过返回值确定到底有没有加锁从而确定要不要解锁。
reentrantLock.lock();//死等加锁
reentrantLock.tryLock();//尝试加锁
reentrantLock.tryLock();//规定时间尝试加锁
ReentrantLock 和 synchronized 的区别:
- synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准 库的一个类, 在 JVM 外实现的(基于 Java 实现).
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock,同时还要搭配try-finally使用
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就 放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启 公平锁模式.
- 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程
实际开发还是使用Sychronied居多。
有么有可能Sychronied和ReentrantLock能不能加锁同一个对象呢?
答案是不可能,原因两个机制是完全不同的,Sy是这对{}里面的对象,本质是操作对象的“对象头”里的特殊的数据结构,这个部分是JVM内部C++实现的代码
而ReentrantLock锁的对象实际就是我们定义的ReentrantLock实例,这是在java代码层面实现的对锁对象的控制。
如何选择使用哪个锁?
- 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
- 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制- 加锁的行为, 而不是死等. 如果需要使用公平锁, 使用 ReentrantLock.
5.3原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference
以 AtomicInteger 举例,常见方法有
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
这个我们之前提过,这里就不过多赘述了
5.4信号量 Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
操作系统里面也有信号量的概念,这里的信号量确实就是操作系统里面那个信号量,只不过这里多了一个java的封装。
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源. 当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作) 当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作) 如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源.Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
代码示例
创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
acquire 方法表示申请资源(P操作),
release 方法表示释放资源(V操作)
创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("我获取到资源了");
Thread.sleep(1000);
System.out.println("我释放资源了");
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
六、线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
多线程环境使用 ArrayList
- 自己使用同步机制 (synchronized 或者 ReentrantLock)
前面做过很多相关的讨论了. 此处不再展开. - Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List. synchronizedList 的关键操作上都带有 synchronized - 使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器(COW写时拷贝)
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到.
- 只适用于队列内存比较小的时候,如果很大拷贝所带来的系统开销很大
所以这种方式很适合服务器程序的配置的维护。因为修改配置可能需要重启服务器才能生效,所以很多服务器都有热部署(热加载)。而热加载使用的就是写时拷贝的思路,新的配置加入到新的对象中,加载过程中,请求仍然需要基于旧的配置进行,在新的对象加载完毕后,再替换。
多线程环境使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列 - LinkedBlockingQueue
基于链表实现的阻塞队列 - PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列 - TransferQueue
最多只包含一个元素的阻塞队列
多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
Hashtable
ConcurrentHashMap(更推荐)
ConcurrentHashMap VS Hashtable
相比于 Hashtable 做出了一系列的改进和优化.
- 最大的优化在于ConcurrentHashMap 相较于Hashtable大大降低锁冲突的概率,把一把大锁,转换成了多把小锁。HashTable线程安全就是在每个方法上加上Sychronized,换句话说,他就是简单粗暴对this加锁,所以基本上只要调用HashTable的方法就有存在加锁和解锁问题。但是实际上hashTable本身在设计上读写冲突就 比一般的数据结构要小。
假如说元素A 和元素B都在同一个链表上(指的是HashTable的每一个hashcode对应一个链表),那么两个不同线程分别对AB元素增删改确实会存在线程安全问题。但是如果AB两个元素在不同链表上,实际不会有线程安全问题。
所以如果是使用hashTable,他是对整个对象都加了锁,随意我对不同链表上AB元素操作也是不能同时进行的。但是他们本身是可以并行的。
那么ConcurentHashmap是怎么做的呢?他是每个链表都会有一个锁,只有申请同一个对象的锁才会有锁竞争,这样就大大降低了锁竞争。