1.常见的锁策略
锁策略是多线程编程中用于控制线程访问共享资源的方式。不同的锁策略适用于不同的场景,选择合适的锁策略可以提高程序的性能和可靠性。
1.1乐观锁和悲观锁
乐观锁:假设并发冲突很少发生,因此在访问共享资源时,不加锁,而是在更新时检查是否有冲突
悲观锁:假设并发冲突一定会发生,因此在访问共享资源时,总是先加锁,确保其他线程无法同时访问
synchronized初始使用乐观锁策略,当发生锁竞争比较频繁的时候,就会自动切换成悲观锁策略
乐观锁的一个重要功能就是要检测出数据是否发生访问冲突,我们可以引入"版本号"来解决,也就是说针对记录的修改操作,提交的版本必须大于记录当前版本才能执行更新操作
1.2轻量级锁和重量级锁
轻量级锁:基于CAS操作的锁机制,假设大多数情况下锁竞争很少,因此通过CAS操作来避免使用操作系统级别的锁,从而减少锁开销
重量级锁:通过操作系统的互斥量实现锁的机制.当锁竞争激烈时,轻量级锁会升级为重量级锁,来确保线程安全
多数情况下,乐观锁,也是一个轻量级锁;悲观锁,也是一个重量级锁,但是这并非完全保证的
其次,我们所提到的CAS以及互斥量在之后会说到
1.3自旋锁和挂起等待锁
自旋锁:是一种典型的轻量级锁,自旋锁是一种忙等待的锁机制.当线程尝试获取锁时,如果所已经被其他线程持有,当前线程不会放弃cpu,而是通过循环不断尝试获取锁,直到成功
挂起等待锁:是一种典型的重量级锁,它是一种阻塞等待的锁机制.当线程尝试获取锁时,如果锁已被其他线程持有,当前线程会放弃CPU,进入阻塞状态,等待被唤醒
synchronized中的轻量级锁策略大概率就是通过自旋锁的方式实现的
1.4互斥锁和读写锁
互斥锁:是一种独占锁,同一时间只允许一个线程访问共享资源.无论是读操作还是写操作,都需要先获取到锁
读写锁:读写锁将锁分为读锁和写锁
- 读锁:允许多个线程同时获取读锁,进行读操作
- 写锁;写锁是独占的,同一时间只允许一个线程获取写锁,进行写操作
读锁和读锁之间没有互斥;写锁和写锁之间,存在互斥;写锁和读锁之间,存在互斥
1.5公平锁和非公平锁
公平锁:公平锁按照线程请求锁的顺序分配锁,保证先请求锁的线程先获取锁.公平锁通过维护一个等待队列来实现
非公平锁;非公平锁不保证线程获取锁的顺序,允许新请求锁的线程插队,非公平锁通过抢占机制来实现
操作系统和java中的synchronized原生都是"非公平锁"
操作系统这里针对加锁的控制,本身就依赖于线程调度顺序的,这个调度顺序是随机的,不会考虑到这个线程等待了锁多久,要想实现公平锁,就得引入额外的东西
1.6可重入锁和不可重入锁
不可重入锁:不允许同一个线程多次获取同一把锁,如果一个线程尝试再次获取已经持有的锁,会导致死锁
可重入锁:允许同一个线程多次获取同一把锁.每次获取锁时,锁的计数器加1;每次释放锁,锁的计数器减1.只有当计数器为0时,锁才被完全释放.
synchronized是可重入锁
2.CAS
CAS(Compare-And-Swap) 是一种用于实现并发控制的原子操作。它是无锁编程(Lock-Free Programming)的核心技术之一,广泛应用于多线程环境中,用于实现线程安全的操作。CAS 操作通过硬件指令直接支持,能够在不使用锁的情况下保证操作的原子性。
2.1CAS的核心思想
CAS操作包含三个操作数:
- 内存地址(V):需要更新的变量的内存地址
- 预期值(A):变量当前的值
- 新值(B):希望将变量更新为的值
操作逻辑:如果内存地址V的值等于预期值A,则将V的值更新为新值B;如果内存地址V的值不等于预期值A,则操作失败,不进行任何更新
CAS示例的伪代码
boolean CAS(V, A, B) {
if (V == A) {
V = B;
return true;
} else {
return false;
}
}
在上述交换过程中,我们并不关心B的后续情况,而是更关心V这个变量的情况
2.2CAS的应用场景
2.2.1实现原子类
package thread;
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo28 {
public static void main(String[] args) throws InterruptedException {
//这些原子类,都是基于CAS实现了自增 自减等操作,此时进行这类操作不需要加锁,线程也是安全的
AtomicInteger count = new AtomicInteger(0);
//使用原子类,来解决线程安全问题
Thread t1 = new Thread(() ->{
for (int i = 0; i < 50000; i++) {
count.getAndIncrement(); //count++
// count.incrementAndGet(); ++count
// count.getAndDecrement(); count--
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i < 50000; i++) {
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count.get());
}
}
通过结果我们可以发现,当我们的count是AtomicInteger类的一个实例时,我们通过两个线程来计算count的最终值,我们可以发现,其实现了线程安全,我们得到的是我们预期想要得到的结果.
2.2.2
AtomicInteger的深层探究
我们先来看一下AtomicInteger的伪代码实现
class AtomicInteger {
private int value;
public int getAndIncrement() {
// 步骤 1:读取当前值
int currentValue = value;
// 步骤 2:尝试更新值
while (true) {
// 尝试将 value 从 currentValue 更新为 currentValue + 1
if (cas(value, currentValue, currentValue + 1)) {
// 如果 CAS 成功,返回更新前的值
return currentValue;
} else {
// 如果 CAS 失败,重新读取 value 的值
currentValue = value;
}
}
}
}
我们现在假设,两个线程同时调用,getAndIncrement()方法
1.两个线程都读取到value的值到currentValue里面(也就是读取到各自的工作内存中)
2.线程1先执行CAS操作,此时currentValue的值和value的值相同,直接对value赋值
3.线程2在执行CAS操作,此时发现currentValue的值和value的值不一样,所以不进行赋值,进入循环
4.线程2接下来第二次执行value操作
这样我们更加直观地看到,为什么实现一个原子类,不要使用重量级锁,就能完成多线程的自增操作
2.2.3实现自旋锁
伪代码示例
public class SpinLock {
private Thread owner = null;
public void lock() {
// 尝试获取锁
while (true) {
// 检查锁是否被其他线程持有
if (owner == null) {
// 尝试将锁的所有者设置为当前线程
if (CAS(this.owner, null, Thread.currentThread())) {
// 如果 CAS 成功,当前线程获取锁,退出循环
break;
}
}
// 如果锁被其他线程持有,继续自旋等待
}
}
public void unlock() {
// 释放锁,将所有者设置为 null
this.owner = null;
}
}
2.3ABA问题
ABA问题具体来说:
- 线程1读取变量的值为A
- 线程2将变量的值从A改为B,在改回A
- 线程1执行CAS操作,发现变量的值仍然是A,误认为变量没有修改
解决方法:
版本号机制,为变量增加一个版本号,每次修改变量的时候,版本号增加1.CAS操作不仅比较值,还比较版本号
3.Synchronized原理
3.1对象头
在java中,每个对象都有一个对象头,对象头中包含了以下信息:
Mark Word:存储对象的哈希码,锁状态等信息
Klass Pointer:指向对象的类元数据
synchronized的锁信息就存储在Mark Word中
3.2Monitor机制
Synchronized 的锁机制是基于 Monitor(监视器) 实现的。每个 Java 对象都与一个 Monitor 相关联,Monitor 的主要组成部分包括:
- Owner:持有锁的线程。
- EntryList:等待获取锁的线程队列。
- WaitSet:调用 wait() 方法后进入等待状态的线程队列。
3.3锁的升级
为了优化synchronized的性能,JVM引入了锁升级机制,锁的状态会从低到高逐步升级
- 无锁状态:对象刚创建,处于无锁状态
- 偏向锁:当第一个线程访问锁时,JVM会将锁升级为偏向锁,偏向锁并不是真正的加锁,仅仅是做了一个标记
- 如果没有后续线程来竞争该锁,那么就不用进行其他同步操作了
- 如果有后续线程来竞争该锁,那么就取消偏向锁状态,转而进入下一阶段,轻量级锁状态
- 轻量级锁:此处的轻量级锁通过CAS来实现的,
- 通过CAS检查并更新一块内存,
- 如果更新成功则认为加锁成功;
- 如果更新失败,则认为锁被占用,继续自旋式等待
- 重量级锁:当轻量级锁竞争激烈时,JVM会将锁升级为重量级锁,重量级锁通过操作系统的互斥量实现,确保线程安全
3.4锁消除和锁粗化
3.4.1锁消除
编译器和JVM判断锁是否可以消除,如果可以,直接消除
3.4.2锁粗化
锁粗化是指JVM将多个连续的锁操作合并为一个更大的锁操作,从而减少锁的获取和释放的次数
public void method() {
synchronized (this) {
// 操作 1
}
synchronized (this) {
// 操作 2
}
synchronized (this) {
// 操作 3
}
}
public void method() {
synchronized (this) {
// 操作 1
// 操作 2
// 操作 3
}
}
4.JUC的常见类
JUC(Java Util Concurrent) 是 Java 并发编程的核心工具包,位于 java.util.concurrent
包中。它提供了丰富的并发工具类,用于简化多线程编程。
4.1Callable接口
Callable是一个泛型接口,定义了一个可以返回结果的任务.与Runnable不同,runnable描述的任务没有返回值,如果需要用一个线程单独计算出某个结果,用Callable是比较合适的
接下来我们使用Callable接口计算1+2+3+...+100的和
package thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class ThreadDemo29 {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//使用callable 计算1 + 2 + 3 .....+100
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();
Integer result = futureTask.get();
//get会发生阻塞,直到callable执行完成 get才阻塞完毕,才能获取到结果
//get会抛出两个异常
System.out.println(result);
}
}
我们可以发现,我们并没有将callable直接提交给线程t,而是用FutureTask将callable包装了一下,原因是因为:
1.callable只是一个接口,定义了任务的逻辑(call()方法),但是本身不能直接提交给线程执行,其本身只是定义了任务的逻辑,但是无法提供任务的状态和结果的查询
2.FutureTask实现了Runnable接口,可以将Callable转换为一个可执行的任务,同时提供了对任务状态和结果的查询方法(get(),isDone(),cancel()等方法)
3.FutureTask 实现了 Runnable 接口,因此可以直接作为任务提交给线程池,也可以通过 Thread 手动启动。这种灵活性使得 FutureTask 可以用于更多的场景。
4.Callable 的 call() 方法可以抛出异常,但线程池无法直接处理这些异常。 FutureTask 会捕获 Callable 抛出的异常,并在调用 get() 时重新抛出,使得异常可以被调用方处理。
我们可以用一个形象的比喻来比喻他们之间的关系
Callable就相当于厨师,FutureTask就相当于外卖平台,厨师只负责做饭(任务),外卖平台负责管理厨师的饭(任务),并且提供一个接口给顾客,厨师不能直接与顾客沟通订单状态,而外卖平台(FutureTask)就充当了一个中间人,负责管理订单的状态和结果.
4.2ReentrantLock
它是一种可重入锁,与synchronized相比,其提供了更灵活的锁机制,支持公平锁,非公平锁等高级功能,相比于synchronized通过wait/notify每次唤醒一个随机线程来比,ReentrantLock搭配Condition可以更精确控制唤醒某个指定的线程.
4.2.1ReentrantLock基本用法
- lock():加锁,如果获取不到锁就死等
- trylock(超时时间):加锁,如果获取不到锁,等待一定时间之后就会放弃加锁
- unlock():解锁
package thread;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadDemo30 {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();//参数写为true就是公平锁
// try{
// reentrantLock.lock();
// }finally {
// reentrantLock.unlock();
// }
boolean result = reentrantLock.tryLock();
try{
if (result){
}else {
}
}finally {
if (result){
reentrantLock.unlock();
}
}
}
}
我们把unlock放入finally中,这样就能确保unlock一定可以执行的到.
5.信号量(Semaphore)
信号量用于控制同时访问特定资源的线程数量,本质上来说就是一个计数器,描述了可用资源的个数
(与操作系统的信号量是一个东西,只不过这个是java把操作系统的原生信号量封装了一下)
代码示例:
package thread;
import java.util.concurrent.Semaphore;
public class ThreadDemo31 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
System.out.println("执行一次p操作");
semaphore.acquire();
System.out.println("执行一次p操作");
semaphore.acquire();
System.out.println("执行一次p操作");
semaphore.acquire();
System.out.println("执行一次p操作");
semaphore.release();
}
}
6.线程安全的集合类
6.1多线程环境使用ArrayList
1.自己加锁,使用synchronized和ReentrantLock
2.Collections.synchronizedList(new ArrayList);这里提供一些ArrayList相关的方法,同时也是带锁的,用这个把集合类一包装
3.CopyOnWriteArrayList
"写时拷贝",当针对ArrayList进行读操作,就无需任何额外的工作,如果进行写操作,则要拷贝一份新的ArrayList,针对新的进行修改,修改过程中如果有读操作就继续读旧的这份数据,当修改完毕使用心得替换旧的.
例如服务器的"热加载"就可以使用写时拷贝的思路,加载过程中,请求仍然基于旧的配置进行工作.当新对象加载完毕,使用新对象代替旧对象.
6.2多线程环境使用哈希表
在多线程环境下使用哈希表可以使用:Hashtable,ConcurrentHashMap
6.2.1Hashtable
只是简单的把关键方法都添加上了synchronized关键字,但是这也导致一个问题,只要操作哈希表的任意元素都会产生加锁,也就是锁冲突.
但是我们实际在对好戏表进行操作的时候,当我们需要修改的元素的值,并没有指向相同的哈希地址,此时我们的同时对两个元素进行修改的操作是没有冲突的,如下图所示:
如果线程1修改元素1,线程2修改元素2,这是有线程安全问题的;如果线程1修改元素1,线程2修改元素3这是没有线程安全问题了,这种情况下完全不需要加锁.
6.2.2ConcurrentHashMap
相比于Hashtable,ConcurrentHashMap做出了一系列调整优化
- 读操作没有加锁,但是使用volatile来保证内存可见性
- 加锁的方式仍然是synchronized,但是不是锁整个对象,而是锁"桶"(每个链表的表头节点)
- 充分利用了CAS特性,避免重量级锁出现
- 优化了扩容方式
- 触发条件:当哈希表中的元素数量超过阈值(sizeCtl)时,会触发扩容。阈值由负载因子(默认 0.75)和当前容量决定。
- 扩容机制:
- 扩容时,会创建一个新的、更大的 Node 数组(通常是原数组的两倍)。
- 使用多线程协作的方式进行扩容:每个线程可以负责迁移一部分数据(称为“分段迁移”)。
- 迁移过程中,使用 synchronized 锁定当前桶(Node),确保线程安全。
- 数据迁移:
- 将旧数组中的每个桶(Node)重新哈希到新数组中。
- 如果桶中是链表,会将链表拆分为两部分,分别迁移到新数组的不同位置。
- 如果桶中是红黑树,会将树拆分为两个链表,然后迁移到新数组。
- 并发控制:
- 使用 CAS 操作来更新扩容状态(如 sizeCtl)。
- 多个线程可以同时参与扩容,每个线程负责迁移一部分数据。