目录
一、常见的锁策略
1、乐观锁 VS 悲观锁
乐观锁和悲观锁是处理锁冲突时的态度。
乐观锁:预测锁冲突发生的概率很低,做的工作就比较少,成本低,效率高。
悲观锁:预测锁冲突发生的概率很高,做的工作就比较多,成本高,效率低。
2、读写锁 VS 普通的互斥锁
普通的互斥锁:只有加锁和解锁两个操作。
读写锁:对于读操作就需要加读锁,对于写锁就需要加写锁,完了之后都必须解锁。
读锁和读锁之间不存在互斥关系,但是读锁和写锁、以及写锁和写锁之间都存在互斥。
读写锁适合不频繁写、频繁读的场景。
3、重量级锁 VS 轻量级锁
重量级锁:做的事情比较多,开销多。
轻量级锁:做的事情比较少,开销小。
上述的悲观锁常认为是重量级锁,乐观锁被认为是轻量级锁。
通常要经过内核态来实现功能的锁被认为是重量级锁,只是在用户态实现功能的是轻量级锁。
4、挂起等待锁 VS 自旋锁
挂起等待锁:通过系统内核的相关机制来进行实现,通常是重量级锁。
自旋锁:通过用户态的代码来进行实现,往往是轻量级锁。
6、公平锁 VS 非公平锁
此处公平的标准是:是否遵循先来后到。
公平锁:多个线程在等待锁的时候,遵守先来后到的原则,谁先来的,谁就先得到该锁。
非公平锁:多个线程在等待锁的时候,不遵守先来后到的原则。
注意:公平锁和非公平锁获取锁的概率是相等的。
7、可重入锁 VS 不可重入锁
可重入锁:对于一个线程连续加两把锁,不会产生死锁现象的锁。
不可重入锁:一个线程连续加两把锁,会产生死锁现象的锁。
8、synchronized锁的性质
对于synchronized锁存在以下性质:
- 既是乐观锁又是悲观锁,根据锁竞争的激烈程度进行自适应。
- 是普通的互斥锁。
- 也是根据锁竞争的激烈程度决定是重量级锁还是轻量级锁。
- 挂起等待锁部分基于重量级锁来实现,自旋锁部分是基于轻量级锁部分来实现。
- 是非公平锁。
- 是可重入锁。
二、CAS
CAS的全称是Compare And Swap,意思就是比较并交换,是一条计CPU指令。
假设内存中的原数据是A,预期的旧值为B,需要修改的值为C,那么一个CAS涉及如下的操作:
- 首先比较A和B是否相等
- 若相等,则将A与C进行交换,将C写入内存
- 最后,返回是否成功交换
1、CAS的伪代码
boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
&address = swapValue;
return true;
}
return false;
}
当多线程同时对某个资源进行CAS操作,只有一个线程会操作成功,别的线程也不会发生阻塞 ,只是会返回操作失败。
CAS是一种乐观锁。
2、CAS的应用
a、实现原子类
在Java的标准库中,有java.util.concurrent.atomic包,其中对int、long等常见的数据类型进行了封装,并且保证了是线程安全的。
public static void main(String[] args) throws InterruptedException {
AtomicInteger atomicInteger = new AtomicInteger(0);
Thread t1=new Thread(()->{
for(int i=0;i<3000;i++){
//此处为自增操作
atomicInteger.getAndIncrement();
}
});
Thread t2=new Thread(()->{
for(int i=0;i<3000;i++){
//此处为自增操作
atomicInteger.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(atomicInteger.get());
}
}
运行结果:
使用CAS实现原子类是线程安全的,虽然是多线程共享同一变量,因为当一个线程预期的旧值与内存的值不相等时就会修改失败,就会进行下一次的循环,也不会发生阻塞。
b、实现自旋锁
private Thread owner=null;
public void lock(){
while(!CAS(this.owner,null,Thread.currentThread())){
}
}
public void unLock(){
this.owner=null;
}
在while循环中如果当前的owner值为null,就将owner值设为当前线程值,如果当前的owner值不为null,当前尝试加锁的线程就会一直重复的进行while循环,也就是自旋(忙等)。
3、CAS中的ABA问题
什么是ABA问题?
ABA问题就是在CAS进行比较的时候不能确定内存中的值是不是未发生任何改动的预期的旧值,就比如说有两个线程,线程1首先拿到内存中的值记录为oldValue,然后将oldValue
与内存中的值进行对比,但是此时有可能是线程2对内存中的值先加1再减1进行了两次操作,虽然此时内存中的值未变,但实际上已经进行了两次修改,此时线程1就无法判断内存中的值是oldValue还是已经经历了一个变化过程。
ABA问题的解决方案:
引入版本号,版本号只能向一个方向变化,例如在线程进行修改内存的值的时候就将版本号加1,使用CAS在将内存中的值进行读到寄存器的时候不但需要读取内存中的数值,还需要记录版本号,在比较的时候,既要对数值与期待的旧值进行比较,还需要比较版本号,如果都是一致的,才会对内存中的值进行交换,交换完对版本号+1。
这样的引入版本号的CAS是一种乐观锁。
三、synchronized的工作原理
synchronized的加锁过程:
首先是未加锁,首个线程加锁就会进入偏向锁状态,也就是尝试加锁的一种状态,只是做了个标记,此处和懒汉模式比较相似,就是在必要的时候才进行加锁,如果有其他多线程也要进行加锁就会进入自旋锁状态,是比较轻量级的一种锁,进一步多线程锁竞争加剧就会进入到重量级锁的状态
synchronized进行锁粒度的优化:
一般情况下synchronized会进行锁粒度从细向粗进行优化,此处的粒度指的是加锁代码涉及的范围,设计的代码范围大,此处锁的粒度就比较粗,反之,锁的粒度就比较细。如果锁之间的代码间隔较小就会触发synchronized进行粗化。
例如:在一段for循环的代码中进行加锁,这样频繁进行加锁就会到导致锁竞争加剧,synchronized就会进行优化,将锁加到整个for循环的外面,使锁的粒度变粗,从而提高代码运行的效率。
synchronized进行锁消除优化:
我们在写代码的时候有时候就会在不该加锁的地方进行上锁,造成资源浪费,这时候就会触发synchronized进行锁消除的优化,例如在使用StringBuffer、Vector等这样在标准库中已经进行了加锁操作的类在单线程环境下使用的话就会进行锁消除。
四、Callable接口用法
Callable接口也是创建线程的一种方式,相比Runnable接口,Callable接口可以让线程具有返回值的,方便使用多线程计算结果。
例如使用Runnable接口创建线程来计算1+2+……+100 的运算结果。
首先需要创建一个类来接收求的sum值,然后创建一个线程t计算结果,利用锁起到join方法的作用,使main线程等到t线程计算完之后才结束。
class Result{
public int sum;
public Object locker=new Object();
}
public class CountSum {
public static void main(String[] args) throws InterruptedException {
Result result=new Result();
Thread t=new Thread(()->{
int sum=0;
for(int i=1;i<=100;i++){
sum+=i;
}
synchronized (result.locker){
result.sum=sum;
result.locker.notify();
}
});
t.start();
synchronized (result.locker){
while(result.sum==0){
result.locker.wait();
}
}
System.out.println(result.sum);
}
}
使用Callable接口创建线程计算1+2+3+……+100的结果。
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable=new Callable() {
@Override
public Object call() throws Exception {
int sum=0;
for(int i=0;i<=100;i++){
sum+=i;
}
return sum;
}
};
FutureTask<Integer> futureTask=new FutureTask<>(callable);
Thread t=new Thread(futureTask);
t.start();
int sum=futureTask.get();
System.out.println(sum);
}
使用Callable接口创建线程时利用泛型可以指定类型, 需要创建匿名内部类并重写call方法,还需要使用FutureTask类对Callable接口进行封装,再创建线程时对FutureTask进行封装,启动线程后,使用futureTask类的get()方法得到返回结果。
五、ReentrantLock
ReentrantLock也是一种可重入锁,与Synchronized一样也是实现线程间的互斥效果。
但是ReentrantLock与Synchronized也存在以下区别:
- synchronized出了代码段之后会自动释放锁,而ReentrantLock需要手动进行lock()加锁和unlock()解锁操作。
- synchronized在多线程申请锁失败时会进入阻塞等待状态,但是ReentrantLock除了阻塞等待还可以使用tryLock()方法等待一段时间后就会直接返回失败。
- synchronized是Java标准库中的,是在JVM外基于Java实现的,ReentrantLock是JVM内部通过C++实现。
- synchronized是非公平锁,但是ReentrantLock默认是非公平锁,但是在实例化对象时可以通过设定参数为true来设置为公平锁。
- synchronized通过wait和notify实现等待唤醒机制,而ReentrantLock搭配Condition类通过设置条件变量可以精确实现等待唤醒机制。
六、Semaphore信号量
Semaphore信号量是一种广义上的锁,也是一个计数器,通过控制可用资源的数目来实现阻塞。
在Semaphore信号量中通过P操作(acquire方法)来申请资源,同时资源数就会减1;当资源数为0时继续进行P操作时就会阻塞。
通过V操作(release方法)释放资源,资源数加1。
可以对之前的生产者和消费者模型来使用信号量进行控制,默认信号量为0,生产者不断进行V操作释放资源,消费者通过P操作申请资源。
public static void main(String[] args) {
Semaphore semaphore=new Semaphore(0);
Thread producer=new Thread(()->{
try {
while(true){
semaphore.release();
System.out.println("生产者生产成功");
Thread.sleep(500);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
producer.start();
Thread consumer=new Thread(()->{
try {
while(true){
semaphore.acquire();
System.out.println("消费者消费成功");
Thread.sleep(500);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});
consumer.start();
}
七、CountDownLatch
CountDownLatch可以同时等待多个线程执行结束。
使用CountDownLatch就好像在进行一场比赛,当所有选手都冲过终点比赛才会结束。
CountDownLatch也应用于多线程的场景,例如下载一个比较大的文件时通常会将文件分成几个部分,由几个线程来进行下载,全部都下载结束之后,文件下载完成。
例如:利用5个线程来实现任务:
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(5);
Runnable runnable=new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName()+"已完成");
countDownLatch.countDown();
}
};
for(int i=0;i<5;i++){
new Thread(runnable).start();
}
countDownLatch.await();
System.out.println("总任务完成");
}
运行结果: