网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
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;
}
}
2. 基于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中的ABA问题
CAS
中的关键,是先比较,再交换,比较其实是在比较当前值和旧值是不是相同,如果两个值相同,就视为是中间没有发生过改变。但是当前值和旧值之间也有可能变了,但是又变回来了,这样的漏洞,在大多数情况下,其实没啥影响,但是,极端情况下也会引起bug。
这就好比, 我们买一个手机, 无法判定这个手机是刚出厂的新手机, 还是别人用旧了, 又翻新过的手机。
ABA 问题引来的 BUG
大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况。
比如转账的例子:
假设 小张 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50操作.
我们期望一个线程执行 -50 成功, 另一个线程 -50 失败.
如果使用 CAS 的方式来完成这个扣款过程就可能出现问题.
正常的过程:
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败.
异常的过程:
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期望更新为 50.
- 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 .
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作这个时候, 扣款操作被执行了两次.这就是 ABA 问题搞的鬼.
ABA问题怎么解决?
给要修改的数据引入版本号. 在 CAS
比较数据当前值和旧值的同时, 也要比较版本号是否符合预期.如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当前版本号比之前读到的版本号大, 就认为操作失败。
当引入版本号之后, t2再尝试进行这里的比较版本操作就发现版本的旧值和当前值并不匹配.因此就放弃进行修改。如果直接拿变量本身进行判定,因为变量的值有加有减,就容易出现ABA的情况.现在是拿版本号来进行判定,要求版本号只能增加,这个时候就不会有ABA问题了 。
这种基于版本号的方式来进行多线程数据的控制,也是一种乐观锁的典型实现。
三、Synchronized 原理
3.1 synchronized
锁特点
- 既是一个乐观锁,也是—个悲观锁.(根据锁竞争的激烈程度,自适应)
- 不是读写锁只是一个普通互斥锁.
- 既是一个轻量级锁,也是一个重量级锁(根据锁竞争的激烈程度,自适应)
- 轻量级锁的部分基于自旋锁来实现.重量级的部分基于挂起等待锁来实现.
- 非公平锁.
- 可重入锁.
3.2 加锁工作过程
JVM
将 synchronized
锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销);如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用);
- 如果更新成功, 则认为加锁成功;
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.也就是所谓的 “自适应”.
重量级锁
如果竞争进一步加剧,就会进入重量级锁状态。
此处的重量级锁就是指用到内核提供的 mutex
.
- 执行加锁操作, 先进入内核态.
- 在内核态判定当前锁是否已经被占用.
- 如果该锁没有占用, 则加锁成功, 并切换回用户态.
- 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起.等待被操作系统唤醒.经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
3.2 synchronized几个典型的优化手段
1.锁膨胀/锁升级
体现了synchronized
能够"自适应"这样的能力.
2. 锁粗化/细化
此处的粗细指的是“锁的粒度"(加锁代码涉及到的范围:加锁代码的范围越大,认为锁的粒度越粗,范围越小,则认为粒度越细)。
锁的粒度是粗还是细?其各有各的好处:
如果锁粒度比较细,多个线程之间的并发性就更高;
如果锁粒度比较粗,加锁解锁的开销就更小;
编译器就会有一个优化:如果某个地方的代码锁的粒度太细了,就会自动判定进行粗化;
如果两次加锁之间的间隔较大(中间隔的代码多),一般不会进行这种优化.如果加锁之间间隔比较小(中间隔的代码少),就很可能触发这个优化。
3.锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
有些代码,明明不用加锁,结果你给加上锁了.编译器就会发现这个加锁好像没啥必要,就直接把锁给去掉了。
有的时候加锁操作并不是很明显,稍不留神就做出了这种错误的决定。
StringBuffer
, Vector
…在标准库中进行了加锁操作,在单线程中用到了上述的类,就是单线程进行了加锁解锁。
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销。
四、JUC(java.util.concurrent) 的常见类
4.1 Callable 接口
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
}
}
- Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务.
- Callable 通常需要搭配
FutureTask
来使用.FutureTask
用来保存Callable
的返回结果.因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定. FutureTask
就可以负责这个等待结果出来的工作.(当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是FutureTask
. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没).
4.2 ReentrantLock
可重入互斥锁. 和 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();
}
}
ReentrantLock 的用法
lock()
: 加锁, 如果获取不到锁就死等.trylock(超时时间)
: 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.unlock()
: 解锁
这里把加锁和解锁两个操作分开了,这种分开的做法不太好,很容易遗漏unlock
(容易出现死锁)当多个线程竞争同一个锁的时候就会阻塞。
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
所以这里用finally来解决这种情况,保证不管是否异常都能执行到unlock ,但这么写比较麻烦。
ReentrantLock 和 synchronized 的区别(为什么有了 synchronized 还需要 juc 下的 lock?)
1.synchronized
是一个关键字(背后的逻辑是VM内部实现的,C++),ReentrantLock
是一个标准库中的类(背后的逻辑是Java代码写的);
2. synchronized
不需要手动释放锁,出了代码块,锁自然释放.ReentrantLock
必须要手动释放锁,要谨防忘记释放.
3.synchronized
如果竞争锁的时候失败,就会阻塞等待,但是ReentrantLock
除了阻塞等待外, 可以通过 trylock
的方式等待一段时间就放弃;
4.synchronized
是一个非公平锁.ReentrantLock
提供了非公平和公平锁两个版本,在构造方法中,通过参数来指定当前是公平锁还是非公平锁;
5.基于synchronized
衍生出来的等待机制是wait notify
,每次唤醒的是一个随机等待的线程;基于ReentrantLock
衍生出来的等待机制是Condition类(条件变量),可以更精确控制唤醒某个指定的线程。
4.3 信号量 Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器。
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 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();
}
}
输出结果:
4.4 CountDownLatch
同时等待 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("比赛结束!");
}
}
输出结果:
4.5 原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个:
- AtomicBoolean
- AtomicInteger
- AtomicIntegerArray
- AtomicLong
- AtomicReference
- AtomicStampedReference
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
例子在二(2.2 -> 1)
中。
五、线程安全的集合类
原来的集合类, 大部分都不是线程安全的.
Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.
5.1 多线程环境使用 ArrayList
- 自己使用同步机制 (synchronized 或者 ReentrantLock)
- Collections.synchronizedList(new ArrayList);
synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized
- 使用 CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本,不会说出现读到一个"修改了一半"的中间状态。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上C C++开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新
原容器的引用指向新的容器。
这样做的好处,就是修改的同时对于读操作,是没有任何影响的,读的时候优先读旧的版本,不会说出现读到一个"修改了一半"的中间状态。所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:
- 占用内存较多.
- 新写的数据不能被第一时间读取到
[外链图片转存中…(img-IpFtKIbp-1715826443426)]
[外链图片转存中…(img-D0iDbvtl-1715826443426)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上C C++开发知识点,真正体系化!
由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新