文章目录
- 一、CAS
- 1.什么是CAS
- 2.CAS有哪些应用
- 1.实现原子类 - AtomicInteger
- 2.基于CAS实现的自旋锁
- 3.CAS的ABA问题
- 二、Synchronized原理
- 1.基本特点
- 2.偏向锁
- 3.锁消除
- 4.锁粗化
- 三、JUC(java.util.concurrent)的常见类
- 1.Callable接口
- 2.ReentrantLock
- 3.信号量Semaphore
- 4.CountDownLatch
- 四、线程安全集合类
- 1.多线程环境使用 ArrayList
- 2.多线程环境使用队列
- 3.多线程环境使用哈希表
一、CAS
1.什么是CAS
CAS:全称Compare and swap ,字面意思,“比较并交换”。
比较交换的是内存和寄存器。
如果一个内存:M
现在还有两个寄存器:A,B
CAS(M,A,B):
如果M和A的值相同,就把M和B里的值进行交换,同时整个操作返回true.
如果M和A的值不相同,无事发生,同时整个操作返回false
交换的本质,是为了把B赋值给M.(寄存器B里的值是啥,我们不太关心,更关心的是M里的情况)
M=B
CAS其实是一个cpu指令。一个cpu指令,就能完成上述比较交换的逻辑。单个cpu指令,是原子的!!!就可以使用CAS完成一些操作,进一步替代“加锁”。————给编写线程安全的代码引入新的思路,基于CAS实现线程安全的方式,也称为“无锁编程”
优点:保证线程安全,同时避免阻塞(效率)
缺点:
1.代码会更复杂,不好理解
2,只能够适合一些特定场景,不如加锁方式更普通
CAS本质上是cpu提供的指令=》又被操作系统封装,提供api=》又被JVM封装,也提供api=》程序员使用。
2.CAS有哪些应用
1.实现原子类 - AtomicInteger
标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.
典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.
AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();
样例:
public static AtomicInteger count= new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Thread t1 =new Thread(()->{
for(int i =0;i<50000;i++)
{
//count++;
count.getAndIncrement();
// //++ count;
// count.incrementAndGet();
// //count --
// count.getAndDecrement();
// //-- count
// count.decrementAndGet();
}
});
Thread t2 =new Thread(()->{
for(int i =0;i<50000;i++)
{
// count++;
count.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
在Java中,有些操作的偏底层的操作,偏底层的操作在使用的时候有更多的注意事项。稍有不慎就容易写出问题,这些操作,就放到unsafe中进行归类。
原子类内部没有使用synchronized加锁的。
native修饰的方法称为“本地方法”
也就是在JVM源码中,使用C++实现的逻辑。=》涉及到一些底层操作。
结论:原子类里面是基于CAS来实现的。
前面说“线程不安全”本质上是进行自增的过程中,穿插执行了。
CAS也是让这里的自增,不要穿插执行,核心思路和加锁是类似的。
CAS则是会通过重试的方式,避免穿插。
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;
}
}
3.CAS的ABA问题
CAS也是多线程中的一种重要技巧,虽然开发中直接使用CAS的概率不大,但是经常会用到一些内部封装了CAS的操作。
还会伴随ABA问题,什么是ABA问题?
CAS进行操作的关键,是通过值“没有发生变化”来作为“没有其他线程穿插执行”判断依据。
但是,这种判定方式不够严谨,更极端的的情况下:可能有另一个线程穿插进来,把值从A->B->B,针对第一个线程来说,看起来好像把这个值,没变,但实际上已经被穿插执行了。
针对ABA问题如果真的出现,其实大部分情况下也不会产生bug,虽然中间穿插一个线程执行了,由于值又改回去了,此时逻辑上不一定会产生bug.
ABA 问题引来的 BUG
假设 滑稽老哥 有 100 存款. 滑稽想从 ATM 取 50 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
- 存款 100. 线程1 获取到当前存款值为 100, 期望更新为 50; 线程2 获取到当前存款值为 100, 期
望更新为 50. - 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中.
- 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 50, 账户余额变成 100 !!
- 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作
这个时候, 扣款操作被执行了两次!!! 都是 ABA 问题搞的鬼
解决方案
给要修改的值, 引入版本号. 在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期
CAS 操作在读取旧值的同时, 也要读取版本号.
真正修改的时候,
- 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
- 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了)
小结:在实际开发中,一般不会直接使用CAS,都是用的封装好的,但是面试中比较容易考到CAS,一旦考察到CAS,一定会涉及到ABA问题。
二、Synchronized原理
1.基本特点
结合之前的锁策略,我们就可以总结出,Synchronized具有以下特性(只考虑JDK1.8):
1.开始时是乐观锁,如果锁冲突频繁,就转换为悲观锁。
2.开始是轻量级锁实现,如果锁如果持有时间较长,就转换为重量级锁。
3.实现轻量级锁的时候大概率用到了自旋锁策略
4.是一种不公平锁
5.是一种可重入锁
6.不是读写锁
synchronized几个重要机制:
1.锁升级
2.锁消除
3.锁粗化
锁升级的过程是单向的,不能再降级了。
2.偏向锁
第一个尝试加锁的线程,优先进入偏向锁状态。
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程. 如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销. 但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.
偏向锁既保证效率,也保证了线程安全。
3.锁消除
锁消除:也是一种编译器优化的手段。
编译器会自动针对你当前写的加锁的代码,做出判断,如果编译器觉得这个场景,不需要加锁,此时就会把你写的synchronized给优化掉。
StringBulider不带synchronized
StringBuffer带synchronized
如果是在单线程中使用StringBuffer,此时编译器就会自动把synchronized给优化掉。
编译器只会在自己非常有把握的时候,才会进行锁消除
偏向锁,这是运行的事情,运行过程中多线程的调度情况不同。
这个线程的锁肯有人竞争,可能没人竞争。
4.锁粗化
锁的粒度。
synchronized里头,代码越多,就认为锁的粒度越粗。代码越少,锁的粒度越细。
粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用多核CPU资源。
但是,如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(涉及到反复的锁竞争)
三、JUC(java.util.concurrent)的常见类
并发(这个包里的内容,主要就是一些多线程相关的组件)
1.Callable接口
也是创建线程的方式,适合于,想让某个线程执行一个逻辑,并且返回结果的时候,相比之下,Runable不关注结果。
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 = 0;i<=1000;i++)
{
sum+=i;
}
return sum;
}
};
//把任务放到线程中进行执行。
FutureTask<Integer> futureTask =new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
//此时的get就能获取到callable里面的返回结果
//由于线程是并发执行的,执行到主线程的get的时候,t线程可能还没执行完。
//没执行完的话,get就会阻塞。
System.out.println(futureTask.get());
}
futureTask是给我们凭借谁来取结果。
小结:线程的创建方式
1.继承Thread,重写run(创建单独的类,也可以匿名内部类)
2.实现Runable重写run(创建单独的类,也可以匿名内部类)
3.实现Callable,重写call(创建单独的类,也可以匿名内部类)
4.使用lambda表达式
5.ThreadFactory线程工厂
6.线程池
2.ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.
ReentrantLock 的用法:
- lock(): 加锁, 如果获取不到锁就死等.
- trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
- unlock(): 解锁
public static void main(String[] args) {
ReentrantLock lock =new ReentrantLock();
lock.lock();
try{
//woking
}finally
{
lock.unlock();
}
}
优势:
1.ReentrantLock,在加锁的时候,有两种方式,lock,tryLock.——tryLock给了更多操作空间。
2.ReentrantLock,提供了公平锁的实现(默认情况下是非公平锁)
3.ReentranLock提供了更强大的等待通知机制。搭配了Condition类实现等待通知的。
虽然ReentrantLock有上述优势,但是咱们在加锁的时候,还是首选synchronized,但是很明显,ReentrantLock使用更复杂,尤其是容易忘记解锁。
3.信号量Semaphore
信号量,用来表示“可用资源的个数”,本质上就是一个计数器。描述了“可用资源”的个数
每次申请一个可用资源,就需要让计数器-1 P操作acquire
每次释放一个可用资源,就需要让计数器+1 V操作 release
(这里的+1和-1都是原子的)
信号量,假设初始情况下数值是10
每次进行P操作,数值就-1
当我已经进行了10次P操作之后,数值就是0了。
如果我继续进行P操作,会咋样?=>阻塞等待!!
锁,本质上就属于是一种特殊的信号量。锁就是可用资源为1的信号量。锁就是可用资源为1的信号量。加锁操作,P操作1-》0,解锁操作,v操作0->1——二元信号量
操作系统,提供了信号量实现,提供了api.JVM封装了这样的api,就可以在java代码中使用了。
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore=new Semaphore(4);
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.acquire();
System.out.println("p操作");
semaphore.release();
}
4.CountDownLatch
这个东西,主要是适用于,多个线程来完成一些列任务的时候,来衡量任务的进度是否完成。
比如需要把一个大的任务,拆分成多个小的任务,让这些任务并发的去执行。
就可以使用countDownLatch来判定说当前这些任务是否全都完成了。
下载一个文件,就可以使用多线程下载。
很多的下载工具,下载速度,很一般。
相比之下,有一些专业下载工具,就可以成倍的提升下载速度(IDM),多个线程下载,每个线程都建立一个连接,此时就需要把任务进行分割。
CountDownLatch主要有两个方法:
- await,调用的时候就会阻塞,就会等待其他的线程完成任务,所有的线程都完成了任务之后,此时这个await才会返回,才会继续往下走。
- countDown,会告诉countDownLatch,我当前这一个子任务已经完成了。
public static void main(String[] args) throws InterruptedException {
//10个选手参赛,await就会在10次调用完,countDown之后才能继续执行。
CountDownLatch countDownLatch = new CountDownLatch(10);
for(int i=0;i<10;i++)
{
int id =i;
Thread t= new Thread(()->{
System.out.println("thread"+id);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//通知说当前任务执行完毕了。
countDownLatch.countDown();
});
t.start();
}
countDownLatch.await();
System.out.println("所有的任务都完成了");
}
四、线程安全集合类
数据结构中大部分的集合类,都是线程不安全的。
Vector,Stack,Hashtable线程安全 用了synchronized
上古时期,Java引入的集合类,现在都不建议使用了,未来会被删除的内容。
1.多线程环境使用 ArrayList
1.针对这些线程不安全的集合类,要想在多线程环境下使用,就需要考虑好线程安全的问题了。
2.同时,标准库,也给我们提供了一些搭配的组件,保证线程安全。
Collections.synchronizedList(new ArrayList);
这个东西会返回一个新的对象,这个新对象,就相当于给ArrayList套上一层壳。这层壳就是在方法上直接使用synchronized的。
3.使用CopyOnWriteArrayList
CopyOnWrite容器即写时复制的容器
- 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,
- 添加完元素之后,再将原容器的引用指向新的容器
2.多线程环境使用队列
- ArrayBlockingQueue
基于数组实现的阻塞队列 - LinkedBlockingQueue
基于链表实现的阻塞队列 - PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列 - TransferQueue
最多只包含一个元素的阻塞队列
3.多线程环境使用哈希表
HashMap 本身不是线程安全的.
在多线程环境下使用哈希表可以使用:
- Hashtable
- ConcurrentHashMap
1)Hashable
只是简单的把关键方法加上了 synchronized 关键字
只要两个线程,在操作同一个Hashtable就会出现锁冲突,但实际上,对于哈希表来说,锁不一定非得这么加,有些情况,其实是不涉及到线程安全问题的。
- ConcurrentHashMap
- ConcurrentHashMap最核心的改进,就是把一个全局的大锁,改进成了每个链表独立的一把小锁。这样做,大幅降低了锁冲突的概率。
- 充分利用到了CAS特性,把一些不必要加锁的环节给省略加锁了。比如需要使用变量记录hash表中的元素个数,此时,就可以使用原子操作(CAS)修改元素个数。
- ConcurrentHashMap还有一个激进的操作,针对读操作没有加锁,读和读之间,读和写之间,都不会有锁竞争。
- ConcurrentHashMap针对扩容操作,做出了单独的优化。本身hashtable或者HashMap在扩容的时候,都是需要把所有元素都拷贝一遍的(如果元素很多,拷贝就比较耗时)化整为零,一旦需要扩容,确实需要搬运,不是在一次操作中搬运完成,而是多分多次,来搬运,每次搬运一部分数据,避免这单次操作过于卡顿。