CAS(CompareAndSwap),字面意思是比较并替换,是一个单个的 CPU 指令,CAS 操作有3个操作数:
更新一个变量的时候,只有当变量的预期值 A 和内存地址 V 当中的实际值相同时,才会将内存地址 V 对应的值修改为 B。整个比较并替换的操作是一个原子操作。
由于 CAS 这个机制,就给实现线程安全版本的代码提供了一个新的思路,之前是通过加锁来把多个指令打包成一个整体,来实现线程安全。使用 CAS 来实现修改操作,则就能保证线程是安全的。
Java 标准库提供了 java.util.concurrent.atomic 包,该包里面的类都是基于 CAS 方式来实现的。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iqZCLM5a-1661619116805)(C:/Users/bbbbbge/Pictures/接单/1661434530281.png)]
例如通过 AtomicInteger 创建的变量,通过两个线程对他并发的进行自增操作各5000次,结果则会是10000,而不会出现小于10000的bug。
public class Demo20 {
private static AtomicInteger count = new AtomicInteger(0);
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for(int i=0; i<5000; i++) {
count.getAndIncrement();
}
});
t1.start();
Thread t2 = new Thread(() -> {
for(int i=0; i<5000; i++) {
count.getAndIncrement();
}
});
t2.start();
try {
t1.join();
t2.join();
}catch (InterruptedException e){
e.printStackTrace();
}
System.out.println(count.get());
}
}
通过 CAS 可以实现自旋锁,实现思路如下:
CAS 虽然能高效的解决原子操作问题,但是仍存在一些问题:
循环时间长,开销大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给 CPU 带来很大的压力。
不能保证代码块的原子性。
CAS 机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用 synchronized 了。
存在 ABA 问题
CAS 的使用流程通常如下:
CAS 修改完成的前提就是第一步中读取的值和预期的旧值是相同的(相同是为了保证在这次操作期间,并没有其它线程修改这个内存的值),但当一个线程准备进行 CAS 操作时,如果有其它线程对内存中该变量的值进行了修改,并且在这期间该变量被修改回了原来的值,那么当第一个线程通过 CAS 操作时,就会误认这个变量是没有被修改过。在某些场景下如果出现这种情况则会出现很大的影响。
ABA 场景:
如果现在一个人的账户有100元,从 ATM 取50,出现极端情况机器卡了,就点了两次取钱,那么就会通过两个线程来完成这件事。两个线程都通过 CAS 的读取操作读取到了内存中余额为100,并且第一个线程通过 判断和修改操作将余额改成了50,但此时有人给这个人突然转帐了50,即这个人的余额又变成了100,此时第二个线程又会进行判断和修该操作,将这个人的余额再次改成50。那么这个人就会一次取款,就拿出了100。
这个漏洞称为 CAS 操作的 ABA 问题。
解决方式:
给 CAS 的变量引入一个版本号(或时间戳),每次修改的时候,让版本号递增即可,只要当前修改的操作的预期的旧版本号和原版本号不同,则说明内存中的值是被修改过的。
Java 并发包为了解决这个问题,也提供了一个带有标记的原子引用类 AtomicStampedReference,这个类可以对某个类进行包装,在内部提供了版本管理的功能,它可以通过控制变量值的版本来保证 CAS 的正确性。
因此,在使用 CAS 前要考虑清楚 ABA 问题是否会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
JVM 将 synchronized 加锁的过程分为无锁、偏向锁、轻量级锁和重量级锁四种状态。会根据锁冲突的激烈情况,进行依次升级。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tYqfYvWD-1661619116807)(C:/Users/bbbbbge/Pictures/接单/1661446421873.png)]
偏向锁:第一个尝试加锁的线程,优先进入偏向锁状态
偏向锁不是真的加锁,只是给对象头做一个偏向锁的标记,记录这个锁属于哪个线程。如果后续没有其它线程来竞争该锁,那么就不回真的加锁,避免了加锁解锁的开销。如果后续有其它线程来竞争该锁,由于该锁的锁对象中已经记录了该锁当前属于哪个新线程,则其它线程无法进行加锁,而当前的偏向锁状态也会取消,会升级成轻量级锁。
轻量级锁:随着其它线程来竞争,偏向锁状态消失,进入轻量级锁状态(自适应的自旋锁)
这里的轻量级锁采用 CAS 来实现一个自旋锁。由于自旋锁是会让 CPU 空转的,会浪费资源,所以当空转的时间或次数多了,就不会继续自旋,而是升级成重量级锁。
重量级锁:如果竞争进一步激烈,自旋不能快速获取到锁状态,就会升级成重量级锁(内核提供的 mutex)。
- 执行加锁操作,先进入内核态。
- 在内核态中判定当前锁是否已经被占用。
- 如果该锁没有被占用,则加锁成功,别切回到用户态。
- 如果该锁被占用了,则加锁失败,此时线程进入阻塞队列中,直到被操作系统唤醒。
锁消除属于一种编译器的优化机制,编译器会智能的分析当前代码是否有必要加锁,如果认为没有必要,就会自动的把代码中的锁给去除。
示例:例如在单线程的环境下使用 StringBuffter
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append("a");
stringBuffer.append("b");
stringBuffer.append("c");
stringBuffer.append("d");
由于 StringBuffer 中自带了锁,所以每 append 一次都会涉及到加锁和解锁,如果在单线程中,由于没有其它线程的竞争,则没有必要进行加锁和解锁,消耗资源。所以编译器就会自动的把以上代码的锁给干掉。
锁粗化属于一种编译器的优化机制,在开发中,使用细粒度锁是期望释放锁的时候其它线程能够使用该锁,但在某些场景中可能并没有其它线程来抢占这个锁,这种情况下,编译器就会自动把锁粗话,避免繁忙的申请和释放锁。
锁的粒度表示当前这个锁对应的代码范围,锁覆盖的代码越多,则认为粒度越粗,反之越细。
Callable 是一个接口,描述了一个任务,通过重写 call 方法来完成该任务的内容。和 Runnable 不同的是 Callable 关注任务的返回值,返回值的类型为 Callable 的泛型参数。
Callable 通常需要搭配 FutureTask 来使用,FutureTask 用来保存 Callable 的返回结果。
Callable 往往是在另一个线程中执行的,什么时候执行结束并不确定。
FutureTask 负责等待 Callable 的结果。
FutureTask 是可取消的异步任务,提供 Future 的基础实现,并实现了 Runnable 接口。 FutureTask 包含了取消与启动计算的方法,查询计算是否完成以及检索计算结果的方法。只有在计算完成才能检索到结果,调用 get() 方法时如果任务还没有完成将会阻塞调用线程至到任务完成。一旦计算完成就不能重新开始与取消计算,但可以调用 runAndReset() 重置状态后再重新计算。
FutureTask 实现了 RunnableFuture 接口,而 RunnableFuture 接口扩展自 Future Runnable 接口,在创建 FutureTask 时可以使用 Callable 接口的实例或者 Lambda 表达式,也可以使用 Runnable 的实例,但内部还是会使用适配器模式转换成 Callable 实例类型。
示例代码:计算 1—100的和
public class Demo21 {
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 <= 100; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
}
}
Java 标准库中提供了 ReentrantLock 类,这个类是一个可重入互斥锁。和 synchronized 不同的是,它通过以下方法进行加和解锁操作:
| 方法 | 说明 |
|---|---|
lock() | 加锁,如果获取不到锁就死等 |
trylock(超时时间) | 加锁,如果获取不到锁,就等待一定的时间之后就放弃加锁 |
unlock() | 解锁 |
ReentrantLock 需要搭配 try-finally 一起使用,否则如果加锁的代码出现问题,导致抛出异常,那么可能导致无法执行到解锁操作。
示例代码如下:
public class Demo22 {
private static int count = 0;
public static void increase(){
count++;
}
private static ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread t1 = new Thread(()->{
lock.lock();
try {
for (int i = 0; i < 50000; i++) {
increase();
}
}finally {
lock.unlock();
}
});
t1.start();
Thread t2 = new Thread(()->{
lock.lock();
try {
for (int i = 0; i < 50000; i++) {
increase();
}
}finally {
lock.unlock();
}
});
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
ReentrantLock 和 synchronized 的区别:
Semaphore 通常我们叫它信号量, 可以用来控制同时访问特定资源的线程数量,本质上是一个计数器。通过协调各个线程,以保证合理的使用资源。
Semaphore 内部维护了一组虚拟的许可,许可的数量可以通过构造函数的参数指定。
Semaphore 和 ReentrantLock类似,获取许可有公平策略和非公平许可策略,默认情况下使用非公平策略。
Semaphore 的 PV(P 申请资源, V 释放资源)操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
理解信号量:
可以把信号量想象成停车场可用车位的计数器,当车位有100个时,表示有100个可用资源。
- 当有车占用一个车位时,相当于申请了一个可用资源,可用车位就-1(这个称为信号量的 P 操作)。
- 当有车离开车位时,相当于释放了一个可用资源,可用车位+1(这个称为信号量的 V 操作)。
- 如果计数器的值已经为0,还尝试申请资源,就会阻塞等待,直到有其它线程释放资源。
示例代码:
public class Demo23 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);
for(int i=0; i<20; i++){
Thread thread = new Thread(()->{
try {
semaphore.acquire();
System.out.println("申请资源成功");
Thread.sleep(1000);
semaphore.release();
System.out.println("释放资源成功");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
}
}
}
使用场景:
通常用于那些资源有明确访问数量限制的场景,常用于限流 。
比如:数据库连接池,同时进行连接的线程有数量限制,连接不能超过一定的数量,当连接达到了限制数量后,后面的线程只能排队等前面的线程释放了数据库连接才能获得数据库连接。
CountDownLatch 是 java.util.concurrent 包中的一个类,它主要用来协调多个线程之间的同步,起到一个同步器的作用。总的来说,CountDownLatch 让一个或多个线程在运行过程中的某个时间点能停下来等待其他的一些线程完成某些任务后再继续运行。
类似的任务可以使用线程的 join() 方法实现,在等待时间点调用其他线程的 join() 方法,当前线程就会等待 join 线程执行完之后才继续执行,但 CountDownLatch 实现更加简单,并且比 join 的功能更多。
使用方式:
示例代码:
public class Demo24 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for(int i=0; i<10; i++){
Thread t = new Thread(()->{
System.out.println(Thread.currentThread().getName() + " 执行完了任务!");
countDownLatch.countDown();
});
t.start();
}
countDownLatch.await();
System.out.println("任务都执行完毕!");
}
}
不足:
CountDownLatch 是一次性的,不可能重新初始化或者修改其内部计数器的值,当 CountDownLatch 使用完毕后,它不能再次被使用。
平常使用的简单的集合类,大部分都是线程不安全的,但也有几个是线程安全的:
不过以上线程安全的集合类只是内部粗暴的加上了 synchronized,但并不是所有场景都适用,而以下将会介绍一些更加好用的线程安全的集合类。
多线程环境下有以下几种方式安全的使用 ArrayList:
使用 synchronzied 或者 ReenactmentLock 等同步机制
通过 Collections.synchronizedList(new ArrayList) 就能得到线程安全的 ArrayList
该方式在以下两种场景下线程是不安全的:
- 使用迭代器 Iterator 进行遍历列表时
- 使用 for-each 遍历列表时
对于以上线程不安全的场景要手动加锁
使用 CopyOnWriteArrayList
- CopyOnWriteArrayList 是 ArrayList的 线程安全版本,是写时复制的容器。
- CopyOnWriteArrayList 是在有写操作的时候会 copy 一份数据,然后写完再设置成新的数据。而读操作的时候就直接读就可以。
- CopyOnWrite 容器能进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
- CopyOnWriteArrayList 适用于读多写少的并发场景。
- CopyOnWriteArrayList 容器运用了读写分离的思想。
- CopyOnWrite容 器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用 CopyOnWrite 容器。
ArrayBlockingQueue
基于数组实现的阻塞队列
LinkedBlockingQueue
基于链表实现的阻塞队列
PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
TransferQueue
最多只包含一个元素的阻塞队列
HashMap 本身是线程不安全的,在多线程环境下可以使用以下线程安全的哈希表:
Hashtable 就是把关键方法加上了 synchronized 关键字,这相当与直接针对 Hashtable 对象本身加锁。
ConcurrentHashMap 相比于 Hashtable 做出了如下优化:
在 jdk1.8 之前 ConcurrentHashMap 采用锁分段技术,即把若干哈希桶分成一个段,针对每个段分别加锁。而 jdk1.8 则不采用锁分段,而是直接在每个哈希桶分配了一个锁,这样锁的粒度就会更细,降低了锁冲突的概率。
除此之外,jdk1.8 相比于之前,还将 ConcurrentHashMap 的数组+链表的实现方式改进成了数组+链表/红黑树的方式,当链表较长的时候(大于等于8)就转换成红黑树。
| 数据结构 | 区别 |
|---|---|
| HahsMap | 线程不安全;key 允许为 null |
| Hashtable | 线程安全,使用 synchronized 锁 Hashtable 对象,效率降低;key 不允许为 null |
| ConcurrentHashMap | 线程安全,使用 synchronized 锁每个链表头节点,锁冲突概率低,且充分利用 CAS 机制和优化了扩容;key 不允许为 null |
死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或全部都在等待某个资源被释放。由于线程被无期限的阻塞,因此程序不能正常终止。
死锁产生的四个必要条件:
以上四个条件都成立时,便形成了死锁。当打破以上任意一个条件,死锁就会消失。
在以上四个条件中,破坏循环等待时最容易实现的,方式如下:
通过锁排序,假设有 N 个线程尝试获取 M 把锁,就可以针对 M 把锁进行编号(1、2、…、M)。N 个线程尝试获取锁的时候,都会按照固定的编号由小到大顺序来获取锁,这样就可以避免环路等待,来避免死锁。
可能产生环路等待的代码:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock2) {
synchronized (lock1) {
// do something...
}
}
}
};
t2.start();
不会产生环路等待的代码:
Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
synchronized (lock2) {
// do something...
}
}
}
}
t2.start();
volatile关键字的用处
volatile 能够保证内存可见性,强制从主内存中读取数据。此时如果有其他线程修改被 volatile 修饰的变量,可以第一时间读取到最新的值。
Java 多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:方法区, 堆区, 栈区, 程序计数器。其中堆区这个内存区域是多个线程之间共享的。只要把某个数据放到堆内存中,就可以让多个线程都能访问到。
Java 创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?
创建线程池主要有两种方式:
- 通过 Executors 工厂类创建创建方式比较简单,但是定制能力有限。
- 通过 ThreadPoolExecutor 创建,创建方式比较复杂,但是定制能力强。
LinkedBlockingQueue 表示线程池的任务队列。用户通过 submit 或 execute 向这个任务队列中添 加任务,再由线程池中的工作线程来执行任务。
在多线程下,如果对一个数进行叠加,该怎么做?
使用 synchronized/ReentrantLock 加锁
使用 AtomInteger 原子操作
Servlet 是否是线程安全的?
Servlet 本身是工作在多线程环境下,如果在 Servlet 中创建了某个成员变量,此时如果有多个请求到达服务器,服务器就会多线程进行操作,是可能出现线程不安全的情况的。
Thread 和 Runnable 的区别和联系
Thread 类描述了一个线程,Runnable 描述了一个任务。在创建线程的时候需要指定线程完成的任务,可以直接重写 Thread 的 run 方法,也可以使用 Runnable 来描述这个任务。
多次 start 一个线程会怎么样?
第一次调用 start 可以成功调用,后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常。
synchronized 加在一个类的两个非静态方法上,两个线程分别同时用这个方法,请问会发生什么?
synchronized 加在非静态方法上, 相当于针对当前对象加锁。
- 如果这两个方法属于同一个实例,线程1能够获取到锁,并执行方法。线程2会阻塞等待,直到线程1执行完毕释放锁,线程2才能获取到锁,并执行方法的内容。
- 如果这两个方法属于不同实例, 两者能并发执行,互不干扰。
进程和线程的区别
进程是包含线程的,每个进程至少有一个线程存在,即主线程。进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间。进程是系统分配资源的最小单位,线程是系统调度的最小单位。