目录
- 1.常见锁策略
- 1.1 乐观锁和悲观锁
- 1.2 读写锁和普通互斥锁
- 1.3 重量级锁和轻量级锁
- 1.4 挂起等待锁和自旋锁
- 1.5 公平锁和非公平锁
- 1.6 重入锁和非重入锁
- 1.7 synchronized锁的特点
- 2.CAS
- 2.1 CAS实现原子类
- 2.2 实现自旋锁
- 2.3 CAS的ABA问题
- 2.4 解决ABA问题
- 3.synchronized的锁优化机制
- 3.1 锁膨胀/锁升级
- 3.2 锁粗化
- 3.3 锁消除
- 4.JUC
- 4.1 Callable
- 4.2 ReentrantLock
- 4.3 信号量
- 4.4 CountDownLatch
1.常见锁策略
1.1 乐观锁和悲观锁
乐观锁:预期锁冲突的概率很低.
乐观锁是对于数据冲突保持一种乐观态度,操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作)只有到数据提交的时候才通过一种机制来验证数据是否存在冲突.
乐观锁做的工作更多少,付出的成本更低,更高效.
悲观锁:预期锁冲突的概率很高.
悲观锁是基于一种悲观的态度类来防止一切数据冲突,它是以一种预防的姿态在修改数据之前把数据锁住,然后再对数据进行读写,在它释放锁之前任何人都不能对其数据进行操作,直到前面一个人把锁释放后下一个人数据加锁才可对数据进行加锁,然后才可以对数据进行操作,一般数据库本身锁的机制都是基于悲观锁的机制实现的.
悲观锁做的工作更多,付出的成本更多,更低效.
1.2 读写锁和普通互斥锁
对于普通的互斥锁,只有两个操作加锁和解锁,只要两个线程针对同一个对象加锁,就会产生互斥.
对于读写锁来说,分成了三个操作: 1.加读锁:如果代码只是进行读操作,就加读锁 2.加写锁:如果代码中进行了修改操作,就加写锁 3.解锁 |
多线程同时读同一个变量不会有线程安全问题,而且在很多场景中,都是读操作多,写操作少(数据库索引).
1.3 重量级锁和轻量级锁
重量级锁和轻量级锁与上面的乐观锁和悲观锁相似,前者可以认为是处理锁冲突的结果.
重量级锁就是做了更多的事情,开销更大,轻量级锁,做的事情更少,开销更小,也可以认为,通常情况下,悲观锁一般都是重量级锁.乐观锁一般都是轻量级锁,但也不是绝对的
在使用的锁中,如果锁是基于内核的一些功能来实现的(比如调用了操作系统提供的 mutex 接口),此时一般认为这是重量级锁(操作系统的锁会在内核中做很多的事情,比如让线程阻塞等待…)
如果锁是纯用户态实现的,此时一般认为这是轻量级锁(用户态的代码更可控也更高效)
1.4 挂起等待锁和自旋锁
挂起等待锁,往往就是通过内核的一些机制来实现的,往往较重,是重量级锁的一种典型实现.
自旋锁往往就是通过用户态代码来实现的,往往教轻轻量级锁的一种典型实现.
1.5 公平锁和非公平锁
公平和非公平指的是遵守先来后到的原则.
公平锁:多个线程在等待一把锁的时候,谁是先来的,谁就能先获取到这个锁(遵守先来后到).
非公平锁:多个线程在等待一把锁的时候不遵守先来后到(每个等待的线程获取到锁的概率都是均等的)
1.6 重入锁和非重入锁
可重入直观来讲,同一个线程针对同一个锁,连续加锁两次,如果出现了死锁,就是不可重入如果不会死锁,就是可重入的.
上图的代码:外层先加了一次锁,里层又对同一个对象再加一次锁
外层锁:进入方法则开始加锁.这次能够加锁成功,当前锁是没有人占用的.
里层锁:进入代码块,开始加锁,这次加锁不能成功,因为锁被外层占用着呢得等外层锁释放了之后,里层锁才能加锁成功,外层锁要执行完整个方法,才能释放.
对于可重入锁来说,上述连续加锁操作,不会导致死锁.
可重入锁内部,会记录当前的锁被哪个线程占用的,同时也会记录一个“加锁次数",线程a第一次加锁的时候,显然能够加锁成功,锁内部就记录了当前的占用者是a,同时加锁次为1.后续a再进行加锁,此时就不是真加锁,而是单纯的把计数给加锁次数为2.
后续解锁的时候,先把计数进行-1,当锁的计数减到0的时候,就真的解锁.
可重入锁的意义就是降低了程序员的负担.(使用成本,提高了开发效率)但是也带来了代价,程序中需要有更高的开销(维护锁属于哪个线程,并且加减计数.降低了运行效率)
1.7 synchronized锁的特点
2.CAS
CAS全称compare and swap:比较和交换
CAS要做的事情,拿着寄存器/某个内存中的值和另外一个内存的值进行比较,如果值相同了,就把另一个寄存器/内存的值,和当前的这个内存进行交换
2.1 CAS实现原子类
public class demo21 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger num=new AtomicInteger();
Thread t=new Thread(()->{
for (int i = 0; i < 5000; i++) {
//相当于num++
num.getAndIncrement();
}
});
Thread t2=new Thread(()->{
for (int i = 0; i < 5000; i++) {
//相当于num++
num.getAndIncrement();
}
});
t.start();
t2.start();
t.join();
t2.join();
System.out.println(num.get());
}
}
这个代码里面不存在线程安全问题,基于 CAS 实现的 ++ 操作这里面就可以保证既能够线程安全,又能够比 synchronized 高效,synchronized 会涉及到锁的竞争两个线程要相互等待,CAS 不涉及到线程阻塞等待
2.2 实现自旋锁
2.3 CAS的ABA问题
ABA的场景出现时,预期值和旧值相同会让线程以为这个值没有被改变过,然而“值相同=没有被改变过”无法成立,即使第二个线程确确实实改过这个值,只不过又改回来了。“值曾经发生过改动”这个事件就无法被观测到。对于需要跟踪值的改变过程(比如记录值改变的次数)的场景来说ABA问题就是致命的
2.4 解决ABA问题
此处就要求每次针对余额进行修改,都让版本号+1,每次修改之前要先对比版本看看旧值和当前值是否一致.
当引入版本号之后,t2 再尝试进行这里的比较版本操作就发现版本的旧值和当前值并不匹配因此就放弃进行修改,如果直接拿变量本身进行判定,因为变量的值有加有减,就容易出现 ABA 的情况
现在是拿版本号来进行判定,要求版本号只能增加,这个时候就不会有 ABA 问题了
3.synchronized的锁优化机制
3.1 锁膨胀/锁升级
体现了synchronized的自适应能力
偏向锁,并不是真的加锁,只是做了一个标记,带来的好处就是后续如果没人竞争的时候就避免了加锁解锁的开销
偏向锁,和懒汉模式也有点像,思路都是一致的,只是在必要的时候进行操作
3.2 锁粗化
此处的粗细指的是"锁的粒度",加锁代码涉及到的范围.加锁代码的范围越大,认为锁的粒度越粗范围越小,则认为粒度越细
到底锁粒度是粗好还是细好? 各有各的好
如果锁粒度比较细,多个线程之间的并发性就更高,如果锁粒度比较粗,加锁解锁的开销就更小,编译器就会有一个优化,就会自动判定说,如果某个地方的代码锁的粒度太细了,就会进行粗化.
如果两次加锁之间的间隔较大(中间隔的代码多),一般不会进行这种优化.如果加锁之间间隔比较小(中间隔的代码少),就很可能触发这个优化
3.3 锁消除
有些代码,不需要加锁,结果上锁了,编译器就会发现这个加锁好像没啥必要,就直接把锁给去掉了。
有的时候加锁操作并不是很明显,稍不留神就做出了这种错误的决定,StringBuffer,Vector等等在标准库中进行了加锁操作,在单个线程中用到了上述的类,就是单线程进行了加锁解锁。
4.JUC
4.1 Callable
java.util.concurrent是关于多线程相关操作的一个类
Callable 是一个 interface 也是一种创建线程的方式,Runnable不太适合于让线程计算值,例如,创建一个线程,让这个线程计算 1 + 2 + 3 + … + 1000如果基于Runnable来实现,就会比较麻烦,Callable 就是要解决 Runnable繁琐的问题
public class demo22 {
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 = 1; i < =1000; i++) {
sum+=i;
}
return sum;
}
};
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t=new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
4.2 ReentrantLock
ReentrantLock和synchronized一样都是可重入锁
基础用法:lock(),unlock(),把加锁和解锁两个操作分开了
这种分开的做法不太好,很容易遗漏unlock (容易出现死锁)
ReentrantLock Lock=new ReentrantLock();
Lock.lock();
try{
//working
}finally {
Lock.unlock(); //最后都会执行到unlock
}
ReentrantLock和Synchronized的区别: 1.synchronized 是一个关键字(背后的逻辑是JVM 内部实现的,C++), ReentrantLock 是一个标准库中的类(背后的逻辑是 Java 代码写的) 2. synchronized 不需要手动释放锁,出了代码块,锁自然释放,ReentrantLock 必须要手动释放锁,要谨防忘记释放. 3.synchronized 如果竞争锁的时候失败,就会阻塞等待,但是 ReentrantLock 除了阻塞等待之外,还有trylock,失败了直接返回. 4.synchronized是一个非公平锁,ReentrantLock 提供了非公平和公平锁两个版本,在构造方法中,通过参数来指定当前是公平锁还是非公平锁. 5.基于 synchronized 衍生出来的等待机制,是 wait和notify,功能是相对有限,基于ReentrantLock 衍生出来的等待机制,是 Condition 类(条件变量),功能要更丰富一些 |
4.3 信号量
Semaphore是一个更广义的锁,锁是信号量里一种特殊情况,叫做“二元信号量",可用资源就一个,计数器的取值,非0即1.
停车场入口一般会有个牌子,上面写着“当前空闲 xx 个车位每次有个车开进去,车位数-1,每次有个车开出来,车位数+1,这个牌子就是信号量,描述了可用资源(车位)的个数
每次申请一个可用资源,计数器就-1(称为 P 操作)
每次释放一个可用资源,计数器就+1(称为 V 操作)
当信号量的计数已经是 0了,再次进行 P 操作就会阻塞等待
如果申请次数超过资源个数,会发生阻塞,直到有资源释放.
public class demo23 {
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore= new Semaphore(5); //申请了5个资源
semaphore.acquire();
System.out.println("申请资源成功");
semaphore.acquire();
System.out.println("申请资源成功");
semaphore.acquire();
System.out.println("申请资源成功");
semaphore.acquire();
System.out.println("申请资源成功");
semaphore.acquire();
System.out.println("申请资源成功");
semaphore.release();
System.out.println("释放资源成功");
semaphore.acquire();
System.out.println("申请资源成功");
}
}
4.4 CountDownLatch
CountDownLatch叫做倒计时锁存器,通俗解释是终点线.
countDown给每个线程里面去调用,就表示到达终点了.
await是给等待线程去调用,当所有的任务都到达终点了,await 就从阻塞中返回,表示任务完成
public class demo24 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch count=new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
Thread t =new Thread(()->{
try {
Thread.sleep(3000);
System.out.println(Thread.currentThread().getName()+"到达终点");
count.countDown();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t.start();
}
count.await();
System.out.println("比赛结束");
}
}