0%

多线程和JUC并发编程学习笔记

前言

这个是个人在学习多线程和并发编程时记录的笔记,仅涉及并发编程的基础知识

1. JUC概述

1.1 什么是JUC

JUC就是java.util.concurrent工具包的简称,是Java中一个处理线程的工具包;

1.2 线程和进程相关概念

1.2.1 线程和进程

  • 进程:是系统进行资源分配和调度的基本单位,进程是程序的实体,同时也是线程的容器;
  • 线程:是操作系统能够进行运算调度的最小单位,被包含在进程中,是进程中的实际运作单位,一条线程指的是进程中一个单一顺序的控制流,在一个进程中可以并发多个线程,每条线程并行执行不同的任务;

1.2.2 wait()和sleep()的区别

  • 相同点:一旦执行方法,都可以使得当前的进程进入阻塞状态;
  • 不同点:
    1. 两个方法声明的位置不同:Thread类中声明sleep(),Object类中声明wait();
    2. 调用的要求不同:sleep()可以在任何需要的场景下调用,wait()必须在同步代码块或同步方法中调用;
    3. 关于是否释放同步监视器:如果两个方法都是用在同步代码块或同步方法中,sleep()不会释放,wait()会释放;

1.2.3 notify()和notifyAll()区别

  • notify( ):一旦执行此方法,就会唤醒被wait的一个线程,如果有多个线程被wait,就唤醒优先级高的那个;
  • notifyAll( ):一旦执行此方法,就会唤醒所有被wait的线程;

注意:

  1. 这两个方法必须使用在同步代码块或同步方法中;
  2. 这两个方法的调用者必须是同步代码块或同步方法中的同步监视器,否则会出现异常;

1.2.4 线程状态

线程在一定条件下状态会发生变化。线程一共有以下几种状态:

  1. **新建状态(New)**:新创建线程对象,如Thread thread = new Thread()。
  2. 就绪状态(Runnable):线程对象创建后,其他线程调用了thread.start()。该状态的线程位于“可运行线程池”中,变得可运行,只等待获取CPU的使用权即在就绪状态的进程除CPU之外,其它的运行所需资源都已全部获得。
  3. **运行状态(Running)**:就绪状态的线程获取了CPU,执行程序代码。
  4. **阻塞状态(Blocked)**:线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。
  • 阻塞的情况分三种:
    • 等待阻塞:运行的线程执行wait()方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify()或notifyAll()方法才能被唤醒,
    • 同步阻塞:运行的线程在获取对象的synchronized同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入“锁池”中。
    • 其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
  1. **死亡状态(Dead)**:线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

1.3 并发与并行

  • 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
  • 并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。

1.4 同步和异步

  • 同步:指发送方发出数据后,等接收方发回响应以后才发下一个数据包的通讯方式。
  • 异步:指发送方发出数据后,不等接收方发回响应,就直接接着发送下个数据包的通讯方式。

1.5 管程

管程(Monitor,又称为监视器)即所说的锁,是一种同步机制,保证在同一个时间只有一个线程能去访问被保护的数据或者代码,JVM同步基于进入和退出,使用管程对象实现的,即加锁和解锁操作;

1.6 用户线程和守护线程

  • 如果JVM中所有的线程都是守护线程,那么JVM就会退出,进而守护线程也会退出;如果JVM中还存在用户线程,那么JVM就会一直存活,不会退出。

    • 守护线程:依赖于用户线程,用户线程退出了,守护线程也就会退出,典型的守护线程如垃圾回收线程。
    • 用户线程:是独立存在的,不会因为其他用户线程退出而退出。

2. Lock接口(Lock锁)

2.1 Synchronized关键字

  1. 修饰一个代码块时,被修饰的代码块称为同步代码块,其作用的范围是大括号{}中的代码,作用的对象是这个代码块的对象;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    格式:
    synchronized (同步监视器) {
    //需要被同步的代码
    }


    说明:1.操作共享数据的代码,即为需要被同步的代码;
    2.共享数据:多个线程共同操作的变量;
    3.同步监视器,俗称:锁,任何一个类的对象都能充当锁,但要求多个线程必须要共用同一把锁;
    补充:实现Runnable接口创建方式,锁可以考虑用实现类对象this表示,此时的this必须是唯一的实现类的对象;
    在继承Thread类创建多线程的方式中,慎用this充当同步监视器,考虑使用当前类充当同步监视器;
  2. 修饰一个方法时,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    格式:
    权限修饰符 synchronized void 方法名() {
    //操作共享数据的代码
    }

    总结:
    1.同步方法仍然涉及到同步监视器,只是不需要显式声明;
    2.非静态同步方法,同步监视器是实现类对象:this
    静态同步方法,同步监视器是当前类本身:类名.class

2.2 多线程编程步骤

第一步:创建资源类,在资源类创建属性和操作方法;

第二步:在资源类操作方法

第三步:创建多个线程,调用资源类的操作方法;

第四步:判断条件写到while中防止虚假唤醒问题;

2.3 Lock接口概述

1
2
3
4
格式:
1.实现类实例化可重入锁ReentrantLock属性:ReentrantLock lock = new ReentrantLock( )
2.重写的run( )中调用锁定方法Lock( ),使后续的代码单线程实现
3.调用解锁方法unlock( )

Synchronized和Lock的区别:

  • 相同点:二者都可以解决线程安全问题;
  • 不同点:
    1. synchronized机制在执行完相应的同步代码后,自动地释放同步监视器,而Lock需要手动的启动同步(Lock( )),同时结束同步也需要手动的实现(unlock( ),建议在finally块中释放锁避免出现死锁;
    2. synchronized是Java的关键字,而Lock是一个类,通过这个类可以实现同步访问;

2.4 创建线程的多种方式

2.4.1 继承Thread类

1
2
3
4
5
6
7
8
9
10
11
步骤:
1.创建一个继承于Thread类的子类;
2.重写Thread类的run( ),将此线程执行的操作声明在run( )中;
3.创建Thread类的子类的对象;
4.通过此对象调用start( );
① 启动当前线程;
② 调用当前线程的run( )方法 ;

注意点:
1.我们不能通过直接调用run( )的方式启动线程,必须通过对象.start( )的方式;
2.再启动一个线程时,需要重新创建一个线程的对象去start( )执行;

2.4.2 实现Runnable接口

1
2
3
4
5
6
7
8
9
10
11
步骤:
1.创建一个实现了Runnable接口的类;
2.实现类去实现Runnable中的抽象方法:run( )
3.创建实现类的对象;
4.将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象;
5.通过Thread类的对象调用start( ),在start( )中调用了Runnable类型target的run( )

// 使用匿名内部类方式进行实现:
new Thread(() -> {
//大括号中调用具体方法
}).start();

比较继承Thread类和实现Runnable接口这两种创建方式:两种方式都需要重写run( ),但开发中优先选择实现Runnable接口的方式
原因:

  1. 实现的方式没有类的单继承性的局限性;
  2. 实现的方式更适合来处理多个线程有共享数据的情况;

2.4.3 使用Callable接口(详情后续章节讲解)

1
2
3
4
5
6
7
8
9
10
11
12
13
步骤:
1.创建一个实现Callable的实现类;
2.实现call( )方法,将此线程需要执行的操作声明在call( )中,可以有返回值;
3.创建Callable接口实现类的对象;
4.将此Callable接口实现类的对象作为参数传递到FutureTask的构造器中,创建FutureTask的对象;
5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start( )方法;
6.获取Callable中call方法的返回值,get()返回值即为FutureTask构造器参数Callable实现类重写的call()的返回值;


//使用匿名实现类方式进行实现:
new Thread(new FutureTask(
() -> return 返回值
)).start();

2.4.4 使用ThreadPool线程池(详情后续章节讲解)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
步骤
1.提供指定线程数量的线程池
ExecutorService executorService = Executors.newFixedThreadPool(线程数量)

2.执行指定的线程的操作,需要提供实现Runnable接口或Callable接口实现类的对象;
executorService.execute(实现类对象):适用于Runnable接口
executorService.submit(实现类对象):适用于Callable接口

3.关闭连接池
executorService.shutdown( );


使用线程池方法的好处:
//提前创建多个线程放入线程池中,使用时直接获取,使用完放回池中,可以避免频繁创建销毁,实现重复利用;
1.提高响应速度,减少创建新线程的时间
2.降低资源消耗(重读利用线程池中的线程,不需要每次都创建)
3.便于线程管理

3. 线程间通信

线程间通信定义:当多个线程共同操作共享的资源时,互相告知自己的状态以避免资源争夺。

3.1 虚假唤醒问题

概念:当一个条件满足时,很多线程都被唤醒了,但是只有其中部分是有用的唤醒,其它的唤醒都是无用功;

解决方法:当有两个线程调用相同的方法时,线程唤醒调用了notifyAll()方法,会唤醒所有线程,这两条线程都会被唤醒,如果用if,因为if只会执行一次,这就会不进行条件判断直接执行下一步的代码,造成了线程虚假唤醒问题;如果用while,线程虽然被唤醒,但还是会进行循环判断直到满足才执行,就避免了线程虚假唤醒的问题;

3.2 线程间通信方式

首先线程通信的模型主要可以分为两种,分别为共享内存消息传递,以下方式都是基于这两种模型来实现的:

以一道实际题目为例:

1
有两个线程A、B,A线程向一个集合里面依次添加元素"abc"字符串,一共添加十次,当添加到第五次的时候,希望B线程能够收到A线程的通知,然后B线程执行相关的业务操作。

3.2.1 共享内存

思路:线程之间共享程序的公共状态,线程之间通过读-写内存中的公共状态来隐式通信;

方式一:使用volatile关键字

基于volatile 关键字来实现线程间相互通信是使用共享内存的思想,大致意思就是多个线程同时监听一个变量,当这个变量发生变化的时候 ,线程能够感知并执行相应的业务。这也是最简单的一种实现方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class TestSync {
//定义一个共享变量来实现通信,必须是volatile修饰的,否则线程不能及时感知
public static volatile boolean notice = false;

public static void main(String[] args) {
List<String> list = new ArrayList<>();
//实现线程A
Thread threadA = new Thread(() -> {
for (int i = 1; i <= 10; i++) {
list.add("abc");
System.out.println("线程A向list中添加一个元素,此时list中的元素个数为:" + list.size());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (list.size() == 5) {
//修改共享变量值
notice = true;
}
}
});
//实现线程B
Thread threadB = new Thread(() -> {
while (true) {
if (notice) {
System.out.println("线程B收到通知开始执行业务");
break;
}
}
});
//先启动线程B
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//再启动线程A
threadA.start();
}
}
方式二:使用JUC中的类 CountDownLatch(详情后续章节讲解)

CountDownLatch基于AQS框架,相当于也是维护了一个线程间共享变量state。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class TestSync {
public static void main(String[] args) {
//创建countDownLatch对象并设置计数器初始值
CountDownLatch countDownLatch = new CountDownLatch(1);
List<String> list = new ArrayList<>();
//实现线程A
Thread threadA = new Thread(() -> {
for (int i = 1; i <= 10; i++) {
list.add("abc");
System.out.println("线程A向list中添加一个元素,此时list中的元素个数为:" + list.size());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (list.size() == 5) {
//计数器减1
countDownLatch.countDown();
}
}
});
//实现线程B
Thread threadB = new Thread(() -> {
while (true) {
if (list.size() != 5) {
try {
countDownLatch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程B收到通知开始执行业务");
break;
}
});
//先启动线程B
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//再启动线程A
threadA.start();
}
}

3.2.2 消息传递

方式三:wait()/notify()结合synchronized等待通知方式

使用Object类的wait() 和 notify() 方法基于线程间消息传递的思想,但要注意wait()和 notify()必须配合synchronized使用,wait())释放锁,而notify()不释放锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestSync {
public static void main(String[] args) {
List<String> list = new ArrayList<>();
//实现线程A
Thread threadA = new Thread(() -> {
synchronized (TestSync.class) {
for (int i = 1; i <= 10; i++) {
list.add("abc");
System.out.println("线程A向list中添加一个元素,此时list中的元素个数为:" + list.size());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (list.size() == 5) {
//唤醒线程B
lock.notify();
}
}
}
});
//实现线程B
Thread threadB = new Thread(() -> {
while (true) {
synchronized (TestSync.class) {
if (list.size() != 5) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程B收到通知开始执行业务");
}
}
});
//先启动线程B
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//再启动线程A
threadA.start();
}
}
方式四:使用 Lock接口中的ReentrantLock结合 Condition
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestSync {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();

List<String> list = new ArrayList<>();
//实现线程A
Thread threadA = new Thread(() -> {
lock.lock();
for (int i = 1; i <= 10; i++) {
list.add("abc");
System.out.println("线程A向list中添加一个元素,此时list中的元素个数为:" + list.size());
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (list.size() == 5) {
condition.singal();
}
}
lock.unlock();
});
//实现线程B
Thread threadB = new Thread(() -> {
lock.lock();
if (list.size() != 5) {
try {
condition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("线程B收到通知开始执行业务");
lock.unlock();
});
//先启动线程B
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//再启动线程A
threadA.start();
}
}

4. 集合的线程安全

4.1 线程安全问题

问题出现原因:当某个线程操作共享数据的过程中,尚未操作完成时,其他线程也参与进来操作同一份共享数据;

解决思路:当一个线程a在操作共享数据时,其他进程不能参与进来,直到线程a操作完共享数据时线程才可以操作共享数据,这种情况即使线程a出现了阻塞也不能被改变;

4.2 ArrayList集合线程不安全和解决方案

ArrayList在向集合添加内容的同时从集合中获取内容可能会产生并发修改问题(ConcurrentModificationException),可见ArrayList是线程不安全的;

解决方案:

方案一:将ArrayList替换成Vector(现在基本不用)
方案二:套用Collections工具类中的synchronizedList(现在很少使用)
1
List<String> list = Collections.synchronizedList(new ArrayList<>());
方案三:使用JUC中的类CopyOnWriteArrayList
1
List<String> list = CopyOnWriteArrayList<>();

CopyOnWriteArrayList实现了写时复制技术,读的过程是并发读,写的过程是先复制一份与原本集合相同的新集合后,往新集合中写入新内容,写入新内容结束后新集合再与原本集合合并,之后读的过程就读取合并的新集合。

4.3 HashSet集合线程不安全和解决方案

解决方案:使用JUC中的类CopyOnWriteArraySet
1
Set<String> set = CopyOnWriteArraySet<>();

4.4 HashMap集合线程不安全和解决方案

解决方案:使用JUC中的类ConcurrentHashMap
1
Map<String, String> map = ConcurrentHashMap<>();

5. 多线程锁

5.1 synchronized锁的情况

synchronized实现同步的基础:Java中的每一个对象都可以作为锁,具体表现为以下三种方式:

  • 对于普通同步方法,所示当前实例对象;
  • 对于静态同步方法,锁是当前类的Class对象;
  • 对于同步代码块,锁是Synchonized()括号中配置的对象;

5.2 锁的分类

一、公平锁与非公平锁
  • 公平锁是指多个线程按照申请锁的顺序来获取锁,效率比非公平锁低。
  • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。其执行效率高,但可能会造成优先级反转或者饥饿现象。

对于Lock接口和Synchronized而言:

  对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

  对于Synchronized而言,也是一种非公平锁,但由于其并不像ReentrantLock是通过AQS来实现线程调度,所以并没有任何办法使其变成公平锁。

二、可重入锁
  • 可重入锁又名递归锁,是指在同一个线程中,在外层方法获取锁的时候,进入内层方法会自动获取锁。

对于Lock接口和Synchronized而言:它们都是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

三、独占锁和共享锁(详情后续章节讲解)

AQS:抽象队列同步器,简单来说AQS就是一个抽象类AbstractQueuedSynchronizer,没有实现任何的接口,仅仅定义了同步状态state的获取和释放的方法。它还提供了一个FIFO队列(先进先出),多线程竞争资源的时候,没有竞争到的线程就会进入队列中进行等待,并且定义了一套多线程访问共享资源的同步框架。

在AQS中的锁类型有两种:分别是Exclusive(独占锁)和Share(共享锁)。

  • 独占锁是指该锁一次只能被一个线程所持有。
  • 共享锁是指该锁可被多个线程所持有。

对于Lock接口和Synchronized而言:

  对于Java ReentrantLock而言其是独占锁。但是对于Lock接口的另一个实现类ReadWriteLock而言,其读锁是共享锁,其写锁是独占锁。读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。独占锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

  对于Synchronized而言其是独占锁。

5.3 一些锁的概念

一、偏向锁、轻量级锁和重量级锁

这三种锁是指锁的状态,并且是只针对Synchronized而言的。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

  • 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

  • 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。

二、乐观锁和悲观锁

乐观锁与悲观锁并不是特指某两种类型的锁,只是人们定义出来的概念或思想,主要是指如何看待并发同步的角度。

  • 乐观锁:乐观锁总是认为不存在并发问题,每次去取数据的时候,总认为不会有其他线程对数据进行修改,因此不会上锁。但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用“数据版本机制”或“CAS操作”来实现。

    • 数据版本机制

      实现数据版本一般有两种,第一种是使用版本号,第二种是使用时间戳。以版本号方式为例:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加1。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

      1
      2
      3
      # 核心SQL代码如下:
      update table set xxx=#{xxx}, version=version+1
      where id=#{id} and version=#{version};
    • CAS操作

      CAS(Compare and Swap 比较并交换),当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

      CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。

  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

使用说明:

  1. 悲观锁适合写操作非常多的场景;乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
  2. 悲观锁在Java中的使用,就是利用各种锁,比如Java里面的同步原语synchronized关键字的实现就是悲观锁。;乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
三、自旋锁

自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。

5.4 死锁

  • 死锁:两个或两个以上进程分别占用对方的同步资源不放弃,都在互相等待对方放弃自己需要的同步资源,就形成了线程的死锁,死锁时线程处于阻塞状态;
产生死锁的原因
  1. 竞争资源

    • 竞争不可剥夺资源(指当系统资源分配给某进程后,不能强行收回,只能在进程用完后自行释放的资源,如打印机等。)
    • 竞争临时资源(指由一个进程产生,被另一个进程使用,短时间后便无用的资源,故也称为消耗性资源,如硬件中断、信号、消息、缓冲区内的消息等。)
  2. 进程间推进顺序非法

    若P1保持了资源R1,将因R2已被P2占用而阻塞;P2保持了资源R2,也将因R1已被P1占用而阻塞,于是发生进程死锁。

产生死锁的四个必要条件
  1. 互斥条件:进程要求对所分配的资源进行排他性控制,即在一段时间内某资源仅为一进程所占用。
  2. 请求与保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
一个死锁的实现例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class TestDeadlock {
public static String str1 = "str1";
public static String str2 = "str2";

public static void main(String[] args){
Thread a = new Thread(() -> {
try{
while(true){
synchronized(str1){
System.out.println(Thread.currentThread().getName()+"锁住 str1");
Thread.sleep(1000);
synchronized(str2){
System.out.println(Thread.currentThread().getName()+"锁住 str2");
}
}
}
}catch (Exception e){
e.printStackTrace();
}
});

Thread b = new Thread(() -> {
try{
while(true){
synchronized(str2){
System.out.println(Thread.currentThread().getName()+"锁住 str2");
Thread.sleep(1000);
synchronized(str1){
System.out.println(Thread.currentThread().getName()+"锁住 str1");
}
}
}
}catch (Exception e){
e.printStackTrace();
}
});

a.start();
b.start();
}
}

/*
代码解释:
程序中有两个线程,分别是线程a和线程b,线程a锁住了str1,获得锁之后休眠1秒钟,同时线程b锁住了str2后,也进行休眠操作。当线程a休眠完了之后去锁str2,但是str2已经被线程b给锁住了,这边只能等待,同样的线程b休眠完之后也要去锁str1,但是str1已经被线程a给锁住了,同样也只能等待,处于永远互相等待的状态这样就产生了死锁。
*/
解决死锁的方法

一、预防死锁:破坏必要条件之一

  1. 资源一次性分配:一次性分配所有资源,这样就不会再有请求了(破坏请求条件)
  2. 只要有一个资源得不到分配,也不给这个进程分配其他的资源(破坏保持条件)
  3. 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不剥夺条件)
  4. 资源有序分配:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏循环等待条件)

二、避免死锁

  1. 以确定的顺序获得锁

    如果必须获取多个锁,可以使用银行家算法进行解决,所有的锁都按照特定的顺序获取。

  2. 超时放弃锁

    Lock接口提供了boolean tryLock(long time, TimeUnit unit) throws InterruptedException方法,该方法可以按照固定时长等待锁,因此线程可以在获取锁超时以后,主动释放之前已经获得的所有的锁,通过这种方式,也可以很有效地避免死锁。

三、解除死锁

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有:

  1. 剥夺并赋予资源:从其它进程剥夺足够数量的资源给死锁进程以解除死锁状态。
  2. 撤消死锁进程:可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用使死锁状态消除为止。

四、Java中死锁情况排查步骤

  1. jps -l:命令定位进程号
  2. jstack 进程号:找到死锁并查看情况

6. Callable接口

6.1 Runnable接口与Callable接口对比

  1. 是否有返回值:Runnable接口没有返回值,Callable接口有返回值;
  2. 是否会抛出异常:Runnable接口如果无法实现不会抛出异常,而Callable接口会抛出异常;
  3. 实现方法不同。Runnable接口使用run(),Callable接口使用call();
  4. 运行Callable可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

6.2 Future接口

Future是一个接口,代表了一个异步计算的结果,它定义了5个方法:

  • boolean cancel(boolean mayInterruptInRunning):取消一个任务,并返回取消结果,参数表示是否中断线程。
  • boolean isCancelled():判断任务是否被取消。
  • Boolean isDone():判断当前任务是否执行完毕,包括正常执行完毕、执行异常或者任务取消。
  • V get():获取任务执行结果,任务结束之前会阻塞。
  • V get(long timeout, TimeUnit unit):在指定时间内尝试获取执行结果。若超时则抛出超时异常

6.3 FutureTask类

类继承结构

  • FutureTask实现了RunnableFuture接口,而RunnableFuture接口继承了Runnable接口和Future接口,所以FutureTask既可以作为Runnable被Thread执行,也可以获取Future异步执行的结果;

  • FutureTask有两个构造方法,一个接收Callable的参数实例,另一个接收Runnable的参数实例,当传入的参数是Runnable时,也会通过**Executors.callable(runnable, result)**方法将其转成Callable类型(即无论哪个构造方法最终都是执行Callable类型的任务),返回值类型为V(指定的泛型类型);

7. JUC辅助类

7.1 减少计数CountDownLatch

CountDownLatch类可以设置一个计数器,然后通过countDown方法来进行减1操作,使await方法等待计数器不大于0,然后继续执行await方法之后的语句;

  • CountDownLatch主要有两个方法countDown和await,当一个或多个线程调用await()方法时,这些方法会阻塞,其他线程调用countDown()方法会将计数器减1,调用countDown方法的线程不会阻塞,当计数器的值变为0时,因await方法阻塞的线程会被唤醒继续执行;

7.2 循环栅栏CyclicBarrier

CyclicBarrier的构造方法**CyclicBarrier(int parties, Runnable barrierAction)**第一个参数是目标障碍数,每次执行CyclicBarrier障碍数会加1,只有达到了目标障碍数才会启动,执行cyclicBarrier.await()之后的语句。可以将CyclicBarrier理解为加1操作;

7.3 信号灯Semaphore

Semaphore管理一系列许可证,每个acquire()方法阻塞,直到有一个许可证可以获得,然后拿走一个许可证;每个release()方法增加一个许可证,这可能会释放一个阻塞的acquire方法。然而,其实并没有实际的许可证这个对象,Semaphore只是维持了一个可获得许可证的数量。

Semaphore的主要方法如下:

  • void acquire():从此信号量获取一个许可,在提供一个许可前一直将线程阻塞,否则线程被中断。

    1. 当调用semaphore.acquire() 方法时,当前线程会尝试去同步队列获取一个许可,获取许可的过程也就是使用原子的操作去修改同步队列的state ,获取一个许可则修改为state=state-1;

    2. 当计算出来的state<0,则代表许可数量不足,此时会创建一个Node节点加入阻塞队列,挂起当前线程;

    3. 当计算出来的state>=0,则代表获取许可成功。

  • void release():释放一个许可,将其返回给信号量。

    1. 当调用semaphore.release() 方法时,线程会尝试释放一个许可,释放许可的过程也就是把同步队列的state修改为state=state+1的过程

    2. 释放许可成功之后,同时会唤醒同步队列中的一个线程。

    3. 被唤醒的节点会重新尝试去修改state=state-1 的操作,如果state>=0则获取许可成功,否则重新进入阻塞队列,挂起线程。

8. ReentrantReadWriteLock读写锁

8.1 读写锁简介

1
2
场景分析:
对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程可以同时读一个资源,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写的操作了。

针对这种场景,JUC提供了读写锁ReentrantReadWriteLock,它表示两个锁,一个是读操作相关的锁,称为共享锁;一个是写相关的锁,称为**排他锁(或独占锁),分别采用其readLock()writeLock()**方法;

注意:所有 ReadWriteLock实现都必须保证 writeLock操作的内存同步效果也要保持与相关 readLock的联系。也就是说,成功获取读锁的线程会看到写入锁之前版本所做的所有更新。

8.2 线程进入读写锁的条件

  1. 线程进入读锁的前提条件:

    • 没有其他线程的写锁;
    • 没有写请求,或者有写请求,但调用线程和持有锁的线程是同一个
  2. 线程进入写锁的前提条件:

    • 没有其他线程的读锁;(读写互斥)
    • 没有其他线程的写锁;(写写互斥)

8.3 读写锁的特性

(1)公平选择性:支持非公平和公平的锁获取方式,默认为非公平锁。

(2)支持可重入:读锁和写锁都支持线程重进入,读线程在获取了读锁后还可以获取读锁;写线程在获取了写锁之后既可以再次获取写锁又可以获取读锁;

(3)锁降级:允许从写锁降级为读锁,实现方式:先获取写锁,然后获取读锁,最后释放写锁,释放读锁。但不允许读锁升级为写锁;

9. BlockingQueue阻塞队列

9.1 阻塞队列使用场景

1
2
场景分析:
对于生产者和消费者模型,通过队列实现两者之间的数据共享。如果生产者和消费者在某个时间段内,万一发生数据处理速度不匹配的情况呢?理想情况下,如果生产者产出数据的速度必须要大于消费者消费的速度,并且当生产出来的数据累积到一定程度的时候,生产者必须暂停生产(阻塞生产者线程),以便等待消费者线程把累积的数据处理完毕;

阻塞队列实现效果:

  • 当队列中没有数据的情况下,消费者端的所有线程都会被自动阻塞(挂起),直到有数据放入队列。
  • 当队列中填满数据的情况下,生产者端的所有线程都会被自动阻塞(挂起),直到队列中有空的位置,线程被自动唤醒。

9.2 阻塞队列核心方法

一、放入数据
  1. offer(E e):在不违反容量限制的情况下,可立即将指定元素插入此队列,成功返回true,当无可用空间时候,返回false。
  2. offer(E o, long timeout, TimeUnit unit): 将给定元素在给定的时间内设置到队列中,如果设置成功返回true, 否则返回false。
  3. put():直接在队列中插入元素,当无可用空间时阻塞当前线程等待。直到有空间才继续;(此方法会阻塞当前执行方法的线程)
  4. add(E e):在不违反容量限制的情况下,可立即将指定元素插入此队列,成功返回true,当无可用空间时,返回illegalStateException异常。(此方法会返回异常)
二、获取数据
  1. poll(time):取走BlockingQueue里队首的对象,若不能立即取出则可以等time参数规定的时间,若还是取不到时返回null;
  2. take():获取并移除队列头部的元素,无元素时候阻塞当前线程等待。直到队列有新的数据被加入可获取; (此方法会阻塞当前执行方法的线程)

9.3 阻塞队列分类

1. ArrayBlockingQueue(常用)
  • 基于数组的阻塞队列实现,在ArrayBlockingQueue内部维护了一个定长数组,以便缓存队列中的数据对象,还保存着两个整型变量,分别标识着队列的头部和尾部在数组中的位置;(由数组结构组成的有界阻塞队列)

  • ArrayBlockingQueue和LinkedBlockingQueue比较:

    • ArrayBlockingQueue在生产者放入数据和消费者获取数据,都是共用同一个锁对象,由此也意味着两者无法真正并行运行,这点尤其不同于LinkedBlockingQueue;
    • ArrayBlockingQueue在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这在长时间内需要高效并发地处理大批量数据的系统中,其对于GC的影响还是存在一定的区别。
  • 创建ArrayBlockingQueue时,我们还可以控制对象的内部锁是否采用公平锁,默认采用非公平锁。

2. LinkedBlockingQueue(常用)
  • 基于链表的阻塞队列实现,与ArrayListBlockingQueue类似,其内部也维持着一个数据缓冲队列(该队列由一个链表构成),当生产者往队列中放入一个数据时,队列会从生产者手中获取数据,并缓存在队列内部,而生产者立即返回;只有当队列缓冲区达到最大值缓存容量时(LinkedBlockingQueue可以通过构造函数指定该值),才会阻塞生产者队列,直到消费者从队列中消费掉一份数据,生产者线程才会被唤醒,反之对于消费者这端的处理也基于同样的原理。(由链表结构组成的有界阻塞队列)
  • LinkedBlockingQueue之所以能够高效的处理并发数据,还因为其对于生产者端和消费者端分别采用了独立的锁来控制数据同步,这也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。
3. DelayQueue
  • DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素;
  • DelayQueue是一个没有大小限制的队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。(使用优先级队列实现的延迟无界阻塞队列)
4. PriorityBlockingQueue
  • 基于优先级的阻塞队列(优先级的判断通过构造函数传入的Compator对象来决定),但需要注意的是PriorityBlockingQueue并不会阻塞数据生产者,而只会在没有可消费的数据时,阻塞数据的消费者。因此使用的时候要特别注意,生产者生产数据的速度绝对不能快于消费者消费数据的速度,否则时间一长,会最终耗尽所有的可用堆内存空间。(支持优先级排序的无界阻塞队列)
  • 在实现PriorityBlockingQueue时,内部控制线程同步的锁采用的是公平锁。
5. SynchronousQueue

一种无缓冲的等待队列(不存储元素的阻塞队列,即单个元素的队列)

10. ThreadPool线程池

10.1 线程池架构

Java中的线程池是通过Executor框架实现的,该框架中使用了Executor、Executors、ExecutorService和ThreadPoolExecutor类;

10.2 线程池基本结构

用户通过使用线程池的execute()方法创建线程,将Runnable提交到线程池中进行执行。当线程池中无空闲线程时,这个新加入的Runnable就会被放入等待队列。当有线程空闲下来的时候,就会去等待队列里查看是否还有排队等待的任务,如果有就会队列中取出任务并继续执行。如果没有线程就会进入休眠。

当我们把一个Runnable交给线程池去执行的时候,这个线程池处理的流程如下:

  1. 先判断线程池中的核心线程们是否空闲,如果空闲,就把这个新的任务指派给某一个空闲线程去执行。如果没有空闲,判断核心线程池是否到达corePoolSize,当当前线程池中的核心线程数还小于 corePoolSize,那就再创建一个新的工作线程来执行任务。

  2. 如果线程池的线程数已经达到核心线程数,并且这些线程都繁忙,判断等待队列是否已满,没满就把这个新任务放到等待队列中。

  3. 如果等待队列又满了,判断当前线程数是否到达maximumPoolSize,如果还未到达,就继续创建工作线程。如果已经到达了,就执行饱和策略,交给RejectedExecutionHandler来决定怎么处理这个任务。

10.3 Executors类提供的四种线程池

1. newSingleThreadExecutor
  • 创建单线程的线程池。这个线程池只有一个线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。
2. newFixedThreadPool
  • 创建指定线程数的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到队列中,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。
3. newCachedThreadPool
  • 创建可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60秒不执行任务)的线程,当任务数增加时,若无可回收,则添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说JVM)能够创建的最大线程大小。
  • 如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为60秒),则该工作线程将自动终止。终止后,如果又提交了新的任务,则线程池重新创建一个工作线程。
4. newScheduledThreadPool
  • 创建定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。

总结:除了newScheduledThreadPool之外,其它线程池内部都是基于 ThreadPoolExecutor类实现的,在实际开发时也通常继承ThreadPoolExecutor类自定义线程池

10.4 ThreadPoolExecutor类中的的七个参数解释:

  1. corePoolSize:线程池核心线程大小

    线程池中会维护一个常驻的最小线程数量,即使这些线程处理空闲状态也不会被销毁,这个最小线程数量即是corePoolSize;

  2. maximumPoolSize:线程池最大线程数量

    一个任务被提交到线程池后,首先会找是否有空闲存活线程,如果有则直接将任务交给这个空闲线程来执行,如果没有则会缓存到工作队列中,如果工作队列满了,才会创建一个新线程,然后从工作队列的头部取出一个任务交由新线程来处理,而将刚提交的任务放入工作队列尾部。线程池不会无限制的去创建新线程,它会有一个最大线程数量的限制,这个数量即由maximunPoolSize指定;

  3. keepAliveTime:空闲线程存活时间

    一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁,这里的指定时间由keepAliveTime来设定;

  4. unit:空闲线程存活时间单位

    unit作为keepAliveTime的计量单位;

  5. workQueue:工作队列(采用阻塞队列)

    务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务。阻塞队列的分类参考9.3 阻塞队列分类

  6. threadFactory:线程工厂

    创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等

  7. handler:拒绝策略

    当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,就会根据拒绝策略进行处理,拒绝策略可分为以下4种:

    • CallerRunsPolicy:将某些任务回退给调用者线程,降低新任务的流量;
    • AbortPolicy(默认策略):直接抛出RejectedExecutionException异常阻止系统正常运行;
    • DiscardPolicy:丢弃无法处理的任务,不予任何处理也不抛出异常;
    • DiscardOldestPolicy:抛弃队列中等待最久的任务,并把当前任务加入队列中尝试再次提交当前任务;

11. Fock/Join分支合并框架(后续补充)

11.1 Fork / Join框架简介

Fork / Join将一个大的任务拆分成多个子任务进行并行处理,最后将子任务的结果合并成最终的计算结果;

  • Fork:把一个复杂任务进行分拆;
  • Join:把分拆任务的结果进行合并;

11.2 Fork / Join框架与线程池区别

Fork/Join框架采用“工作窃取”模式(work-stealing):当执行新的任务时它可以将其拆分成更小的任务执行,并将小任务加到线程队列中,然后再从一个随机线程的队列中偷一个并把它放在自己的队列中。

相对于一般的线程池实现,Fork/Join框架的优势体现在对其中包含的任务的处理方式上。在一般的线程池中,如果一个线程正在执行的任务由于某些原因无法继续运行,那么该线程会处于等待状态。而在Fork/Join框架实现中,如果某个子问题由于等待另外一个子问题的完成而无法继续运行,那么处理该子问题的线程会主动寻找其他尚未运行的子问题来执行,这种方式减少了线程的等待时间和提高了性能。

12. CompleteableFuture异步回调(后续补充)