欢迎关注个人主页:逸狼
创造不易,可以点点赞吗~
如有错误,欢迎指出~
目录
常见的锁策略
乐观锁vs悲观锁
重量级锁vs轻量级锁
挂起等待锁vs自旋锁
公平锁vs非公平锁
可重入锁vs不可重入锁
读写锁
synchronized的加锁过程
锁升级的过程
偏向锁
锁消除(编译器的优化策略)
锁粗化(编译器的优化策略)
CAS
CAS具体的使用场景
通过CAS实现的原子类
CAS能保证线程安全
ABA问题
ABA问题 举例
引入版本号解决上述问题
Callable 接口
使用举例
ReentrantLock可重入锁
ReentrantLock与synchronized区别
信号量Semaphore
CountDownLatch
解决线程不安全问题
CopyOnWriteArrayList集合类
写时拷贝
写时拷贝的缺点
Hashtabel
ConcurrentHashMap
常见的锁策略
锁 是一个非常广义的话题,synchronized只是市面上其中最典型一种锁的实现(Java内置的,推荐使用的锁)
乐观锁vs悲观锁
- 乐观锁:加锁时,假设出现锁冲突的概率不大 =>接下来围绕加锁要做的工作,就会更少
- 悲观锁:加锁时,假设出现锁冲突的概率很大 =>接下来围绕加锁要做的工作,就会更多
synchronized这把锁 属于'自适应'锁
使用synchronized,初始情况下 是乐观的(预估接下来锁冲突概率不大),同时会在背后统计锁冲突了多少次,如果发现冲突的次数达到一定程度了,就会转变为 悲观的
重量级锁vs轻量级锁
效果和 乐观悲观是重叠的(乐观悲观 是站在"预估锁冲突"角度,重量轻量 站在 加锁的开销 角度)
- 重量级锁:加锁的开销比较大,要做更多的工作.
- 轻量级锁:加锁的开销比较小,要做的工作相对更少..
synchronized也是自适应的
挂起等待锁vs自旋锁
挂起等待锁:悲观锁/重量级锁的一种典型实现
自旋锁: 乐观锁/轻量级锁的一种典型实现
自旋锁使用 '忙等' 的策略:等待的过程中,不会释放cpu资源(不停的检测锁 是否被释放,一旦锁被释放了,就立即有机会能够获取到锁了)
挂起等待锁则"让出了cpu资源"(cpu就可以用来做别的事情了)
synchronized是 自适应的
公平锁vs非公平锁
在计算机中,约定了"先来后到"为公平
synchronized属于非公平锁(概率相等)
- 当N个线程竞争同一个锁.其中一个线程先拿到锁了.后续该线程释放锁之后,剩下的N-1个线程,就是要重新竞争,谁拿到锁,就都不一定了(当然,也不能保证这些线程竞争中获取锁的概率一定是数学上的严格均等)
- 本身操作系统内核里针对锁的处理就是如此,synchronized在系统内核的基础上,没有做啥额外的工作
如果需要 使用公平锁,就需要做额外的操作(比如引入队列,记录每个线程加锁的顺序)
可重入锁vs不可重入锁
针对死锁问题: 如果一个线程,针对一把锁,连续加锁两次,就可能出现死锁.如果把锁设定成"可重入"就可以避免死锁了.
可重入锁的原理:
- 记录当前是哪个线程持有了这把锁
- 在加锁的时候判定,当前申请锁的线程,是否就是锁的持有者线程
- 计数器,记录加锁的次数.从而确定何时真正释放锁.
读写锁
读写锁,本身也是系统内置的锁
读写锁把加锁操作 分为两种情况:读加锁 和 写加锁,读写锁提供了两种加锁的api :加读锁,加写锁,他们解锁的api是相同的
- 如果两个线程,都是按照读方式加锁,此时不会产生锁冲突
- 如果两个线程,都是加写锁,此时会产生锁冲突
- 如果一个线程读锁,一个是写锁,也会产生锁冲突
如果多个线程同时读这个变量,没有线程安全问题,但是一个线程读 且一个线程写 或者两个线程都写 就会产生问题(大部分场景,读操作的频次比写操作 要高)
synchronized不是读写锁
synchronized的加锁过程
代码执行到synchronized的代码块时,jvm大概要做哪些事情?
锁升级的过程
synchronized加锁时会经历:无锁=> 偏向锁=>轻量级锁=> 重量级锁
偏向锁
偏向锁不是真的加锁(真的加锁,开销比较大),只是做了个标记(标记的过程,非常轻量高效)
偏向锁 本质上是推迟了 加锁的时机
对于当前JVM的实现来说,上述锁升级的过程,属于"不可逆"
锁消除(编译器的优化策略)
编译器会对你写的synchronized代码做出判定,判定是否需要真的加锁,如果这里没必要加锁,就能够自动把synchronized给干掉.
锁粗化(编译器的优化策略)
锁的粒度:在synchronized的{}里,代码越多,"粒度越粗";代码越少,"粒度越细"
锁粗化就是把多个"细粒度"的锁,合并为"粗粒度"的锁
CAS
CAS全称Compare and swap
一个内存的数据和两个cpu寄存器中的数据进行操作(寄存器1和寄存器2),比较内存 和 寄存器1中的内容,如果发现相同,就交换内存和cpu寄存器2的内容.(一般只关心 内存交换后的内容(这里的交换希望达到的目的是"赋值")),如果不同,无事发生
CAS具体的使用场景
基于CAS实现"原子类"
int/long 在进行++,--的时候,都不是原子的
基于CAS实现的原子类,对int/long等这些类型进行了封装,从而可以原子的完成++,--等操作
原子类,在Java标准库中也有现成的实现
通过CAS实现的原子类
实际开放中,一般很少直接使用CAS,都是使用现成的操作
CAS能保证线程安全
CAS之所以能保证线程安全,是因为在通过CAS比较的过程中,确认了当前是否有其他线程插入进来执行
ABA问题
value=A,oldvalue=A,value可能被其他线程修改成了B,又被另一个线程修改回了A,是value值从A到B再到A过程,value依然等于oldvalue,所以在CAS判断下,会进行交换操作.
CAS中确实存在ABA问题,但是大多情况下ABA问题不会带来bug
ABA问题 举例
使用CAS逻辑进行转账操作(极端的例子)
引入版本号解决上述问题
版本号是一个"整数"(不一定是"次数",也可以是"时间"),只能增加,不能减
Callable 接口
- callable接口 -> call方法=>带有返回值
- Runnable -> run方法=> void
使用举例
public class Demo33 {
private static int result;
public static void main(String[] args) throws InterruptedException {
Thread t=new Thread(()->{
int sum=0;
for (int i = 0; i < 1000; i++) {
sum+=i;
}
result=sum;
});
t.start();
t.join();
System.out.println("result= "+result);
}
}
使用举例2
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class Demo36 {
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();
// 后续需要通过 FutureTask 拿到最终的结果.
System.out.println(futureTask.get());
}
}
ReentrantLock可重入锁
ReentrantLock属于经典风格的锁,是通过lock和unlock方法完成加锁解锁的
实际开发中,大多情况下使用synchronized即可(synchronized只是Java提供的其中一种加锁的方式)
ReentrantLock与synchronized区别
- synchronized属于关键字,底层是通过JVM的c++代码实现的
- ReentrantLock则是标准库提供的类,通过Java代码实现的
- synchronized通过代码块控制加锁解锁
- ReentrantLock通过调用lock ,unlock方法来完成,(unlock可能会忘记=>将unlock放到finally中)
- ReentrantLock提供了tryLock这样的加锁风格,tryLock在加锁失败时,不会阻塞,会直接返回,通过返回值来反馈是加锁成功还是失败(前面介绍的加锁,都是发现锁被别人占用了,就阻塞等待)
- ReentrantLock还提供了公平锁的实现(默认是非公平的,可以在构造方法中 传入参数,设定成公平的)
- ReentrantLock还提供了功能更强的"等待通知机制",基于Condition类,能力要比wait ,notify更强一些
信号量Semaphore
信号量 是一个"计算器",通过计数器衡量"可用资源"个数,操作系统本身提供了 信号量的实现,JVM把操作系统的 信号量封装了一下
例如
import java.util.concurrent.Semaphore;
import java.util.concurrent.locks.ReentrantLock;
public class Demo37 {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
ReentrantLock locker = new ReentrantLock(true);
Semaphore semaphore = new Semaphore(1);
//值为1的信号量 就相当于"锁"
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// locker.lock();
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// locker.unlock();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
// locker.lock();
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException();
}
// locker.unlock();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count = " + count);
}
}
CountDownLatch
很多时候需要把一个大任务拆成多个小任务,通过多线程/线程池执行 ,借助CountDownLatch就能衡量出当前任务是否整体执行结束
比如多线程下载,通过多线程下载提高下载速度,多个线程每个线程下载一部分,所有线程下载完毕在进行拼装
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Demo39 {
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(4);
// 构造方法的数字, 就是拆分出来的任务个数.
CountDownLatch countDownLatch = new CountDownLatch(20);
for (int i = 0; i < 20; i++) {
int id = i;
executorService.submit(() -> {
System.out.println("下载任务 " + id + " 开始执行");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("下载任务 " + id + " 结束执行");
// 完毕 over!!!
countDownLatch.countDown();
});
} // end for
// 当 countDownLatch 收到了 20 个 "完成" , 所有的任务就都完成了.
// await => all wait
// await 这个词也是计算机术语. 在 python / js 意思是 async wait (异步等待)
countDownLatch.await();
System.out.println("所有任务都完成");
}
}
解决线程不安全问题
ArrayList Queue HashMap...都是线程不安全的,对于Vector Stack Hashtable(内置了synchronized)等线程安全的来说实际上又不建议使用
解决方案
1.自己加锁
2.如果需要使用ArrayList/LinkedList 这样的结构,标准库中提供了一个带锁的List
CopyOnWriteArrayList集合类
这个集合类没有加锁,通过"写时拷贝"来实现线程安全
第三个解决方案就是通过写时拷贝,来避免两个线程同时修改一个变量
写时拷贝
- 如果只是读取,ArrayList不需要进行任何改变
- 如果有其他线程 修改ArrayList上面的元素,此时不会进行修改,而是拷贝一份新的ArrayList
- 拷贝过程中,读操作 都仍然读取旧版本的内容
- 写操作,则是在新版本的内容上修改
如果修改操作直接基于旧版本来修改,同时还有其他线程去读,就容易读到"修改一半的数据"
ArrayList有的修改是原子的,也有一些修改不是原子的,比如"插入/删除操作
写时拷贝的缺点
- 无法应对多个线程同时修改的情况
- 如果涉及到的数据量很大,拷贝起来就非常慢
3..想多线程环境下使用队列,用BlockingQueue
4.多线程环境下使用哈希表,Hashtable虽然是可选项,但是推荐使用ConcurrentHashMap,这个数据结构相对于HashMap和Hashtable来说,改进力度比较大
Hashtabel
- Hashtable的加锁,就是直接给put ,get等方法上加上synchronized(就是给this加锁), 整个哈希表 对象就是一把锁,任何一个针对这个哈希表的操作,都会发生锁竞争
ConcurrentHashMap
- ConcurrentHashMap是给hash表中每个"链表"进行加锁(不是一把锁,而是多把锁),这个方式大大降低了锁冲突的概率,只有进行的两次修改,恰好在修改同一个链表上的元素时,才会触发锁竞争
- ConcurrentHashMap引入了CAS原子操作,针对像 修改size这样的操作,直接借助CAS完成,并不会加锁
- 针对读操作,做了特殊的处理,通过volatile以及一些精巧的代码实现,确保读操作不会读到"修改一半的数据"
- 针对hash表的扩容,做了特殊的优化. 普通hash表扩容,需要创建新的hash表,把元素都搬运过去,这一系列操作,很可能就在一次put就完成了,就会使这次put开销非常大,耗时非常长.ConcurrentMap进行了"化整为零",不会在一次操作中 进行所有数据搬运,而是只搬一部分. 此时后续的每次操作,都会触发一部分key的搬运,最终把所有的key 都搬运完成