锁的分类
隐式锁
简单来说synchronized就是隐式锁,支持重入。synchronized是JDK内置的,内部全部实现好了,我们只需要在合适的地方使用就可以了。隐式锁又分为对象锁和类锁,下面通过一个简单的例子来了解一下:
/**
* synchronized 内置锁
*/
public class SyncTest {
private int count = 0;
private Object lock = new Object(); // 对象锁
public synchronized void add1(){
count++;
}
public void add2(){
synchronized (lock){
count++;
}
}
public void add3(){
synchronized (this){
count++;
}
}
}
上面代码中的三个方法都是使用的对象锁。不同的是add2()是使用的自己创建的一个对象,而add1()和add3()两个方法是等价的,使用的锁对象就是SyncTest这个对象本身。
public class Manager {
private static Manager manager;
public static Manager getInstance() {
if (manager == null) {
synchronized (Manager.class) {
if (manager == null) {
manager = new Manager();
}
}
}
return manager;
}
}
这一段代码就是我们经常使用的DCL方式来实现单例模式。其中synchronized后面的括号中使用的是Manager这个类的类对象,也就是Managerc.class的对象。需要注意的是这个类对象只会存在一份。那么使用类对象来作为锁的方式就叫类锁。
显式锁
虽然使用synchronized可以解决并发问题,但是一切都是JDK内部实现好的,我们不能修改,不够灵活。为了解决这些问题就引入了显式锁,最常用的就是可重入锁ReentrantLock:
public class LockTest {
private int count = 0;
private Lock mLock = new ReentrantLock();
public void add() {
mLock.lock();
try {
count++;
} finally {
mLock.unlock();
}
}
}
上面就是最简单的一个ReentrantLock的简单使用。ReentrantLock 实现了Lock接口,Lock接口提供了公共的行为:
public interface Lock {
// 锁住
void lock();
// 锁中断
void lockInterruptibly() throws InterruptedException;
// 尝试获取锁
boolean tryLock();
// 在规定时间段内尝试获取,时间超过之后则放弃获取
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
// 释放锁
void unlock();
// 创建一个条件
Condition newCondition();
}
公平锁和非公平锁
如显式锁和隐式锁之分,锁也有公平和非公平之分。非公平锁的效率要高于公平锁。这是因为公平锁是按照申请顺序来的,就像排队一样,在前面的总会先执行,后面的后执行。例如有A,B,C,D 4个线程同时执行一段程序,申请到锁的先后顺序A,B,C,D,那么在A线程执行的时候,B,C,D三个线程是需要被挂起的然后重新进入可执行状态,当A执行完了,那么就需要B来执行,那么就要发生一次上下文切换,一次上下文切换要消耗5000~20000个时间周期,所以效率就低了。而非公平锁则属于抢占式的,先到先得,没有抢到的也不会被挂起,只是在等待下一次机会。synchronized 天生就是非公平的,无法修改,但它会导致线程阻塞,阻塞了就会发生上下文切换,所以synchronized的效率比较低。可是ReentrantLock是可以自己配置的,方式也很简单,就是在构造方法中传入true 或者 false,true则是公平锁,false或者不传则是非公平锁。
死锁
死锁指的是两个或者两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象。若无外力的作用,它们都将无法推进下去。此时称系统处于死锁状态或者系统产生了死锁。
生活版
都知道2020年是多灾多难的一年,新年伊始,我们国家就陷入了新冠肺炎的阴影中,于是口罩就成为了大家稀有资源。此时有两个人老张和老王同时去药店买口罩,可是此时货架上仅仅只剩下了一包N95口罩和一包普通的医学口罩。老张抢到了N95,老王则拿到了普通口罩。但是现在疫情严重,拿到了普通口罩的老王觉得如果去人口密集的地方普通口罩效果没有N95那么好,于是他也想要一包N95。而老张则认为如果去人很少的地方戴N95有点浪费,普通口罩完全够用了。于是两个人就都想着对方把口罩让给自己。现在疫情这么严重,口罩这么稀缺,这肯定是不行的。于是两个人就在那里互不相让。那么在想同时拥有N95和普通医学口罩这件事情上就产生了死锁问题。
那么怎么解决这个问题呢?
1:在两个人都互不相让的时候,恰巧药店老板说在自己的柜台上还放了一包N95口罩,可以出售,于是老王抢先去买了那包N95,终于老王喜滋滋的回家了。此时只剩下老张孤单的身影伫立在药店门口。
2:两个人为了口罩的问题越吵越凶,打起来了。最后招来了警察。警察调解的时候出了个主意:两个人通过石头剪刀布的方式,谁赢了口罩就全归谁。两人也都欣然同意了。最后老张今天运气不错赢了。于是老张高高兴兴完成了买口罩的任务。
根据以上的例子总结一下死锁的必要条件就是:
有多个操作者(M >= 2)的情况下,争夺多个资源(N >= 2,且N <= M)才会发生死锁的情况。显然如果只有一个人去买口罩了,那么所有的口罩他都可以买。反之如果只剩下了一包N95,那两个人无非就是谁先抢到算谁的。另外还有两个要求:1.争夺资源的顺序不一样,如果一致的话也不会产生死锁 2.拿到资源不放手
学术版
- 互斥条件:指进程对所分配到的资源进行排它性使用。即在一段时间之内某资源只能由某一个线程使用,如果此时还有其他进程请求资源,则请求者只能等待,直到占有资源的进程用完释放
- 请求和保持条件:指进程已经至少保持一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但又对自己已获得的资源保持不放
- 不剥夺条件:指进程已获得的资源在未使用完之前不能被剥夺,只能在使用完时由自己释放
-
环路等待:指在发生死锁时,必然存在一个进程-资源的环形链,即进程集合{P0,P1,P2,...,Pn}中的P0正在等待P1占用的资源,P1正在等待P2占有的资源,......,Pn正在等待P0占有的资源
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生。
打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造
打破不可抢占条件:当一个进程占有一分独占性资源后又申请一份独占性资源而无法满足,则退出原有的占有资源
打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。避免死锁常见的算法有有序资源分配法、银行家算法。
死锁的危害
- 线程不工作了,但是整个程序还是活着的
- 没有任何的异常信息可供检查
- 一旦发生了死锁是没有任何办法可以恢复的,只能重启程序
如何解决
解决死锁的关键是保持拿锁的顺序一致:1.内部通过顺序比较,确定拿锁的顺序 2.采用尝试拿锁的机制
下面通过代码来认识一下死锁的产生以及如何解决:
死锁的产生
public class Test {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
public static void test1() {
String name = Thread.currentThread().getName();
synchronized (lock1) {
System.out.println(name + " get lock1");
synchronized (lock2) {
System.out.println(name + " get lock2");
}
}
}
public static void test2() {
String name = Thread.currentThread().getName();
synchronized (lock2) {
System.out.println(name + " get lock2");
synchronized (lock1) {
System.out.println(name + " get lock1");
}
}
}
public static class DeadLockThread1 extends Thread{
private String name;
public DeadLockThread1(@NonNull String name) {
super(name);
this.name = name;
}
@Override
public void run() {
super.run();
Thread.currentThread().setName(name);
test1();
}
}
public static class DeadLockThread2 extends Thread{
private String name;
public DeadLockThread2(@NonNull String name) {
super(name);
this.name = name;
}
@Override
public void run() {
super.run();
Thread.currentThread().setName(name);
test2();
}
}
public static void main(String[] args) throws InterruptedException {
DeadLockThread1 thread1 = new DeadLockThread1("张无忌");
DeadLockThread2 thread2 = new DeadLockThread2("郭靖");
thread1.start();
thread2.start();
}
}
解决死锁
private static Lock loc1 = new ReentrantLock();
private static Lock loc2 = new ReentrantLock();
public static void test1() throws InterruptedException {
String name = Thread.currentThread().getName();
Random random = new Random();
while (true) {
if (loc1.tryLock()) {
try {
System.out.println(name + "try get lock1");
if (loc2.tryLock()) {
try {
System.out.println(name + "try get lock2");
System.out.println(name + "使用了乾坤大挪移");
break;
} finally {
loc2.unlock();
}
}
} finally {
loc1.unlock();
}
}
Thread.sleep(random.nextInt(3));
}
}
public static void test2() throws InterruptedException {
String name = Thread.currentThread().getName();
Random random = new Random();
while (true) {
if (loc2.tryLock()) {
try {
System.out.println(name + " try get lock2");
if (loc1.tryLock()) {
try {
System.out.println(name + " try get lock1");
System.out.println(name + "使用了降龙十八掌");
break;
} finally {
loc1.unlock();
}
}
} finally {
loc2.unlock();
}
}
Thread.sleep(random.nextInt(3));
}
}
(之所以使用随机数让线程休眠是为了让两个线程拿锁的时间错开,避免活锁的出现。因为是在死循环中执行的,如果不错开还有可能出现一人拿一个锁的情况,就会再次的释放锁再去获取锁,这种情况可能出现多次。看起来线程都在工作,但都是无用功)
根据结果可以知道张无忌尝试去拿lock2的时候发现已经被郭靖抢到了,于是把lock1也放弃了。那么郭靖就可以拿到lock1了,于是郭靖就执行了。自然张无忌也可以顺利执行。一般来说如果不使用synchronized嵌套,很少出现死锁的情况。所以在使用的时候要多加注意!
活锁
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。
解决办法:每个线程休眠随机数,错开拿锁的时间。
线程饥饿
指低优先级的线程,总是拿不到执行时间
利用锁的机制实现生产者和消费者模式
假设现在有一个面包厂,为了减少库存,需要生产一个面包就需要卖掉之后再生产另外一个。很显然我们需要一个生产面包的线程和一个消费的线程以及面包这三个角色:
面包角色:
public static class Bread {
int id;
public void put() {
id += 1;
System.out.println("生产了面包 ---" + id);
}
public void get() {
System.out.println("--------消费了面包 ---" + id);
}
}
生产者角色:
public static class Producer implements Runnable {
private Bread bread;
public Producer(Bread bread) {
this.bread = bread;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
bread.put();
}
}
}
消费者角色:
public static class Consumer implements Runnable {
private Bread bread;
public Consumer(Bread bread) {
this.bread = bread;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
bread.get();
}
}
}
定义好角色之后,我们就来尝试使用看看能否正确的执行:
Bread bread = new Bread();
Producer producer = new Producer(bread);
Consumer consumer = new Consumer(bread);
Thread t1 = new Thread(producer);
Thread t2 = new Thread(consumer);
t1.start();
t2.start();
先消费了面包0,后来又重复生产了面包1,整个结果都是错乱的,这显然是不合理的。这是因为多线程的带来的问题。既然是多线程,那么加上锁之后看看能否解决呢?修改代码如下:
public synchronized void put() {
id += 1;
System.out.println("生产了面包 ---" + id);
}
public synchronized void get() {
System.out.println("--------消费了面包 ---" + id);
id -= 1;
}
生产时有序的,消费也是有序的,也没有出现错乱,但是没有实现生产一个消费一个的需求,还是会有库存。实际要达到这种效果则是需要两个线程交替执行,那么就需要用到wait 和 notify 机制,下面是最终修改后的代码:
// 标识生产还是消费
private boolean flag;
public synchronized void put() {
if (!flag) {
id += 1;
System.out.println(Thread.currentThread().getName() + "生产了面包 ---" + id);
flag = true;
notify();
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public synchronized void get() {
if (flag) {
System.out.println("--------" + Thread.currentThread().getName() + "消费了面包 ---" + id);
flag = false;
notify();
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
以put()方法为例,首先flag默认为false,那么取反就是true,就会进入整个条件语句。进入之后就把flag修改为true防止再次进入。接着调用notify()来唤醒其他线程(这里是一个空唤醒,因为消费者线程没有被挂起),因为这里明确知道需要唤醒的是哪个线程,所以没有使用notifyAll(),一般都是使用notifyAll()。接着就调用wait()释放锁,使自己进入就绪状态,等待被其他线程唤醒。get()方法流程和这个类似。这样就完成了生产一个消费一个,不需要库存的方式。
网友评论