目录
锁策略
锁策略不仅在Java的多进程中需要考虑,大多数情况下需要考虑锁的情况都有可能涉及到锁策略。
乐观锁VS悲观锁
这是两种类型的锁,都是预测锁冲突的大小角度而考虑的。
乐观锁:预测多个线程访问同一变量的概率比较小,基本不会发生锁冲突。所以每次访问共享变量的时候直接尝试获取变量,同时识别当前的数据是否出现了访问冲突。乐观锁的实现可以引入一个版本号,借助版本号来识别出数据是否发生冲突。
悲观锁:预测多个线程访问同一变量的概率比较大,很有可能发生锁冲突。所以每次访问共享变量的时候都要先加锁。悲观锁的实现就要先加锁,获取到锁再进行操作,没有获取到就等待。
轻量级锁VS重量级锁
轻量级锁:对于加锁的开销比较小。加锁机制不依赖操作系统提供的mutex锁。仅有少量的内核用户态切换,也不容易引发线程调度。
重量级锁:对于加锁的开销比较大。加锁机制很依赖操作系统提供的mutex锁。有大量的内核用户态切换,容易引发线程调度。
加锁本质:
自旋锁VS挂起等待锁
自旋锁:获取锁时,如果获取失败,并不会放弃CPU进行阻塞,而是立刻又获取锁。往复循环,知道获取到锁为止。
优点:①如果锁释放比较快,那么这样就可以在第一时间获取到锁。
②它这种方法由于没有放弃CPU,不涉及到线程阻塞和调度,所以这是轻量级锁。
缺点:如果锁迟迟不释放,那么这样就会一直消耗CPU资源。
挂起等待锁:获取锁时,如果获取失败,直接放弃CPU,然后阻塞等待。如果被唤醒了,才会进行加锁。
优点:不占用CPU资源。
缺点:①无法第一时间获取到锁。
②由于涉及到线程的阻塞和调度,所以这是重量级锁。
互斥锁VS读写锁
互斥锁:如果一个线程获取到锁了,如果还有想获取该锁,就只能进行阻塞等待。不管是这两个的操作是读还是写。
读写锁:读写锁提供三种操作:①对读操作加锁 ②对写操作加锁 ③解锁
读锁和读锁之间没有互斥
读锁和写锁之间存在互斥
写锁和写锁之间存在互斥
公平锁VS非公平锁
设想一个场景,如果多个线程A,B,C获取锁。若A线程获取锁成功。随后B线程也想获取锁,失败然后阻塞等待;C线程最后也想获取,失败然后阻塞等待。过了一段时间,A线程释放了锁:
公平锁:因为释放前B线程比C线程先要获取锁,所以B线程获取到锁。按照先来后到的顺序。
非公平锁:B、C线程一起竞争锁,都有可能获取到锁。随机分配给都想要获取锁的线程。
可重入锁VS不可重入锁
可重入锁:一个线程可以多次获取一把锁,而且不会出现死锁的情况。在Java中,Reentant开头命名的锁都是可重入锁,JDK提供的所有所有现成的Lock实现类也都是可重入的。
不可重入锁:一个线程可以多次获取一把锁,但是出现死锁的情况。
synchronized原理
synchronized特性
根据上面的锁策略,我们可以简单分析以下synchronized所具有的特性。
1. 刚开始是乐观锁,如果锁冲突的概率变大之后就会升级成悲观锁。
2. 刚开始是轻量级锁,如果锁迟迟不释放,就会升级成重量级锁。
3. synchronized的轻量级锁很有可能是自旋锁。
4. 它是互斥锁。
5. 它是非公平锁。
6. 它是可重入锁。
synchronized优化机制
synchronized内部的优化机制需要有大概的认识。
加锁过程优化
加锁过程如下图:
加锁不是一下子就到重量级锁,秉持着能不加就不加,能加轻的就加轻的原则来加锁的。
锁消除
编译器和JVM会自动判断锁是否可以消除,如果可以消除就不进行加锁。
比如在单线程环境下使用一些自身带有synchronized的类,比如StringBuffer。这样加锁和解锁是没有必要的,只会浪费资源。
锁粗化
锁的粒度有粗细之分。
粗:表示这个锁的范围比较大;细则相反。
一般情况下,锁越细越好,这样释放锁的时候别的线程也可以获取到锁。
不过编译器和JVM会判断,如果没有其他线程来抢占该锁,就会自动把锁粗化。这样就不用频繁的加锁和解锁了。
CAS
CAS概念
CAS全称:Compare And Swap 比较和交换 一个CAS涉及到以下的操作。这些操作都是原子性的,相当于CPU当中的一条指令。
CAS原理
操作系统不同,JVM对于CAS实现原理有所不同。但是基本上都是按照以下思路来的:
Java的CAS利用的是unsafe这个类提供的操作。
unsafe这个类是靠JVM针对不同的操作系统实现的Atomic::cmpxchg。
CAS应用
CAS相较于synchronized的使用,并不是那么的广泛。主要有自旋锁的实现和原子类。
自旋锁的实现
可以简单的理解为以下两步:
step1:自旋锁中有一个Thread locker引用——目的为了指向加锁的线程。初始值为Null
step2:locker与Null比较,如果相等(意味着这个锁没有被其他线程持有),就把当前线程的引用赋 值给locker;如果不相等(意味着这个锁已被其他线程持有),就继续尝试,直到成功或者变 成挂起等待锁。
原子类
这个类在java.util.concurrent.atomic中。它是为单个变量提供线程安全的编程。
如下代码实例:
import java.util.concurrent.atomic.AtomicInteger;
public class ThreadDemo30 {
public static void main(String[] args) throws InterruptedException {
AtomicInteger integer = new AtomicInteger(0);
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
// 这个方法相当于自增
integer.getAndIncrement();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
integer.getAndIncrement();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(integer);
}
}
CAS在其中的作用就用这个自增方法举例。
在这个自增方法中,其中初始的 V = 0(也就是我们设定的0);
想要自增时,先要记录这个 V的值,用 A 保存(有可能此时V就被其他线程修改了)。
自增的值为 B = V + 1;
要想把B赋值给V,先要检查 A == V ?就把B赋值给V(相等说明未被修改) : 就啥也不干(不相等说明V被修改了) (这一步是原子的,不怕有线程安全问题)
CAS中的ABA问题
ABA:一个变量本来值为A,然后变成了B,最后又变成A的意思。
在CAS中,当一个线程想要把B赋值给V时,想要用A与V比较。
A这个变量是共享变量。有可能另外一个线程在比较之前把A变了,然后又变回去了。
一般情况下这也不会出bug,但是不排除极端情况。
解决方法是引入版本号。比如给A加个版本,那么这时则是比较的A的版本号有无变化。
在Java中用AtomicStampedReference<V>来实现带有版本的功能。
JUC常用类和接口
JUC是java.util.concurrent的简称,这个包下面大多是和多线程有关的类和接口。
线程池常用类和接口
已在这篇文章中详细介绍了。
原子类
在上述CAS问题中也介绍过了。
ReentrantLock类
这个类在java.util.concurrent.locks包中。
这个类的功能与synchronized关键字的功能很像。都是可重入互斥锁,用来保证线程安全的。
构造方法
// 默认构造方法创建的是非公平锁
ReentrantLock lock1 = new ReentrantLock();
// 带有boolean参数的构建方法可以设置是否为公平锁
// true -- 公平 false -- 非公平
ReentrantLock lock2 = new ReentrantLock(true);
实例方法
这里只介绍常用的实例方法
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadDemo31 {
public static void main(String[] args) {
ReentrantLock lock1 = new ReentrantLock();
// 第一种加锁方式
// 获取不到锁就一直等着
lock1.lock();
// 第二种加锁方式
// 到了设定时间获取不到锁,就放弃加锁
try {
lock1.tryLock(10, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 解锁 建议放到finally中
// 因为有可能加完锁后执行不到解锁这个操作
finally {
lock1.unlock();
}
}
}
synchronized唤醒是通过Object类中的wait和notify来随机唤醒线程的;
而reentrantlock唤醒是通过Condition类来指定唤醒某个线程的。
Semaphore类
这个类是把信号量封装起来了。信号量简单理解为可用资源的个数,本质上是一个计数器。
信号量最重要的操作就是申请资源(P操作)和释放资源(V操作)。
可以设想一下火车票,一趟火车的总票数就是可用资源。当有人买一张,那么资源就少一个(P);当有人退票,那么资源就会多一个(V)。如果总票数剩余为0,要么候补有人退票,要么放弃这趟火车,另寻它车。
如果资源只有1个的时候,它就变成了锁。拥有了锁,也就把资源变成0了(资源不能为负数),释放了锁,资源又变成1。
构造方法
// 创建出有4个资源的信号量。获取资源是随机的
Semaphore semaphore1 = new Semaphore(4);
// 创建出10个资源的信号量。
// true 尽可能是公平获取资源——先release的先获取(不是一定)
// false 随机获取,与第一种构造方法一样
Semaphore semaphore2 = new Semaphore(10, true);
实例方法
这里只介绍P操作和V操作的方法。这些方法都是原子性的,所以在多线程环境下可以使用。
try {
// acquire() 表示获取资源
// 同时也要有异常 因为有可能没有资源,这样就得阻塞等待
// 只要有阻塞,一般都要有InterruptedException这个异常
semaphore1.acquire();
System.out.println("获取到一个资源!");
// release() 表示释放资源
semaphore1.release();
System.out.println("释放一个资源!");
} catch (InterruptedException e) {
e.printStackTrace();
}
使用Semaphore来自增一个变量,使用两个线程自增(保证线程安全)。
import java.util.concurrent.Semaphore;
class SemaphoreLock {
private int n = 0;
public Semaphore semaphore = new Semaphore(1);
public void add (){
n++;
}
public int getN() {
return n;
}
}
public class ThreadDemo33 {
public static void main(String[] args) throws InterruptedException {
SemaphoreLock lock = new SemaphoreLock();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
try {
// 获取到资源后才能自增
lock.semaphore.acquire();
lock.add();
// 自增一次后就释放资源
lock.semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
try {
lock.semaphore.acquire();
lock.add();
lock.semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(lock.getN());
}
}
CountDownLatch类
通过下面的代码来理解这个类具体有什么作用。
构造方法
// 创建三个定数器
// 不能为负数,否则会抛出异常
CountDownLatch count = new CountDownLatch(3);
实例方法
import java.util.concurrent.CountDownLatch;
//import java.util.concurrent.TimeUnit;
public class ThreadDemo34 {
public static void main(String[] args) throws InterruptedException {
// 创建三个定数器(相当于三个选手)
CountDownLatch count = new CountDownLatch(3);
// t线程是跑步比赛的裁判
Thread t = new Thread(() -> {
try {
// 这个方法会让 t 线程阻塞
// 除非计数器被消耗光了才会解除阻塞
count.await();
// 这是另外一个会让 t 线程阻塞的方法
// 有了时间的控制,如果超过设定的时间还在阻塞,就会解除阻塞
// count.await(100, TimeUnit.MINUTES);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("跑步比赛已完成!");
});
// t1/2/3线程是三位选手
Thread t1 = new Thread(() -> {
// 调用该方法,计数器会减一
count.countDown();
System.out.println("t1比赛已完成!");
});
Thread t2 = new Thread(() -> {
count.countDown();
System.out.println("t2比赛已完成!");
});
Thread t3 = new Thread(() -> {
count.countDown();
System.out.println("t3比赛已完成!");
});
t.start();
//t.join();
t1.start();
t2.start();
t3.start();
}
}
Callable接口
这个接口和Runnable接口很像,二者都是用来描述一个任务的。
不同之处:①该接口可以返回一个结果,不返回则会抛出异常。
②该接口实现的类不能直接放到Thread类的构造方法,需要包装一下。
import java.util.concurrent.*;
public class ThreadDemo35 {
public static void main(String[] args) {
// 在t线程下计算1 + 1,只要结果
// 由于这个任务需要返回结果,所用用Callable接口描述任务
// 由于结果的整数,所以用Integer类作为泛型参数
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return 1 + 1;
}
};
// 任务不能直接放到线程里工作
// Thread t = new Thread(callable) 这样是不行的
// 需要使用FutureTask包装以下
// 这个类实现了Runnable接口,所以可以放到Thread的构造方法中
FutureTask<Integer> task = new FutureTask<>(callable);
Thread t = new Thread(task);
t.start();
try {
// get()方法是拿到结果
System.out.println(task.get());
// 结果不可能立刻出来(1+1还是可以立刻出来的),在没出来之前要进行阻塞等待
// 阻塞就要有InterruptedException异常
} catch (InterruptedException e) {
throw new RuntimeException(e);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
}
死锁
死锁是什么
死锁是多个线程同时被阻塞,它们中一个或多个线程都在等待锁释放,导致了僵持的场面,程序陷入死循环的局面。
死锁产生的原因
1. 互斥使用。当资源被一个线程使用时,其他线程无法使用。
2. 不可抢占。资源请求者不能强已被获取的资源,只能等拥有者主动释放。
3. 资源保持。当资源拥有者再去请求其他资源时,原本持有的资源也不能放弃。
4. 循环等待。比如A等B释放,B等C释放,C等A释放资源,这样就形成了循环。
当这四个条件都成立的时候才会形成死锁。
解决死锁
上述任何一个条件不成立就可以解决死锁问题。
其中最容易破坏的是 循环等待。通过最常用的死锁阻止技术:锁排序。
假设有N个线程获取M把锁,把M把锁进行编号(1、2......M)。当形成死锁时,按照标号由小到大的顺序依次获取锁,这样就可以解决循环等待。
有什么错误评论区指出。希望可以帮到你。