欢迎关注个人主页:逸狼
创造不易,可以点点赞吗~
如有错误,欢迎指出~
目录
扫描二维码关注公众号,回复: 17420028 查看本文章![]()
常见的锁策略
锁 是一个非常广义的话题,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 都搬运完成