java 并发 - 使用多线程技术

时间:2022-04-05 18:03:10

(更新中…)
(随后更新代码…)

概述

本文主要内容参考了《疯狂java讲义》(第二版)。本文从整体框架上进行了说明,如需进一步深究,可查看相关的资料。

文章侧重于具体的实现,易使人获得较直观的感受。这篇文章主要针对( java 并发 - 《java并发编程实战》总结,之前这篇文章太过于抽象,理解起来困难且无法给人留下深刻印象),这篇文章可以看做上一篇文章的直观表述。

基本介绍

什么是线程?

理解1:执行流角度】当一个程序运行时,内部可能有多个顺序执行流,每个顺序执行流就是一个线程

理解2:硬件角度】一个程序运行时,同时有多个程序计数器。而程序计数器的作用是,指向要处理的指令(代码)。

理解3:图灵机角度】在图灵机的纸带上,有多个读写头在修改着纸带上的内容。

线程与进程

线程与进程有何区别,联系?

多线程的优势

线程的生命周期

之所以把线程的生命周期单独列为一大节,是因为线程生命周期 是 线程非常重要的性质。之后提到的各种机制都从某种程度上依赖于其生命周期机制(比如,线程通信,锁机制)

思考
1.线程的阻塞、就绪等的关系
2.线程间协作、通信线程阻塞、就绪的关系

生命周期图

图片来自:(Java多线程(二)、线程的生命周期和状态控制
java 并发 - 使用多线程技术

线程生命周期详解

对照线程生命周期图理解下面各状态的含义。

新建状态

(1)【可控:代码控制】new 关键字之后, 进入该状态。
(2) 和其他java对象一样(只是分配了内存)。
(3) 【非动态】没有表现任何动态特征,线程执行体不会执行。

就绪

(1)【代码控制条件:可控】调用start()方法启动线程,准备执行run()方法。但不会立即执行,而是立马进入就绪状态。

(2)运行状态的线程主动调用 yield() 方法。

(3)【注意】不能单独调用run方法,当成普通对象的调用而不会启动新的线程。

运行

(1)【基本情况】就绪状态 + 获得CPU —> 执行run方法体。

(2)【注意:不可控】从就绪状态 到 运行状态,通常不受程序程序控制。(而阻塞的时间可控)

(3)【锁状态 与 运行状态】锁使一段代码具有了原子性,当不意味着它这个原子一下子在CPU中运行完,锁住的内容也可以在中间被打断。(锁的本质,是逻辑上的互斥,而非硬件上的)

阻塞

(1)阻塞的含义是什么?(暂停)

线程阻塞通常是指一个线程在执行过程中暂停,以等待某个条件的触发
这里所说的暂停,是指人为可控的暂停(调用sleep()等方法)。(相反,就绪到运行是不可在程序中控制的

(2)进入阻塞状态的条件

(2.1)主动放弃资源【可能不太对】
* 线程调用sleep()方法主动放弃处理器。
* 调用阻塞式IO。
*【锁 与 阻塞】 试图获得同步监控器,且该同步监控器被其他线程持有。
* 【线程通信】线程等待某个通知(notify)
* 程序调用suspend()方法将线程挂起。(不建议使用)

(2.1) 被动放弃资源
* 时间片用完

(3) 阻塞 –> 就绪 的条件
* sleep()了指定时间之后。
* 线程调用阻塞IO已经返回。
* 【锁与阻塞】获得了试图获取的同步监视器。
* 【线程通信】等待通知时,其他线程发出了一个通知。
* 处于挂起状态的线程被调用了Resume()恢复方法。

死亡

变为死亡状态的条件
* run() 或 call() 方法执行完成,线程正常结束。
* 线程抛出未捕获的Exception或Error。
* 直接调用Stop()方法结束线程(易死锁)。

注意: 已经处于死亡状态的线程,无法再次start()方法运行。

控制线程

java一切皆对象

线程也理所应当被建模成对象的形式

对象具有一些外部可见的方法,通过这些方法我们可以在程序中与对象(线程)交互。 —> 我们(在程序中)通过线程对象,来控制线程的状态

控制线程的哪些状态?

我们应该控制线程的哪些状态呢?主要归结为如下两点:

(1)生命周期的状态

(2)状态切换的时机(也可以称为条件),主要关注人为(程序)可控的部分。

注意:
区分:资源状态 与 线程状态】锁是资源的状态,而不是线程的状态。

非人为可控】线程从就绪到运行,依赖于操作系统的调度,这是由操作系统控制的,而非人为可控制的。

人为可控】通过在代码中调用sleep()方法,使线程从运行变为就绪,认为是人为可控的。(我们可以选择是否插入sleep(),以及sleep的时间)

具体控制实现(语法)

线程的创建

(1)创建线程类: 继承Thread类
(2)创建线程类:实现Runnable接口
(3)创建线程:使用Callable 和Future

线程的启动(就绪状态)

MyThread mt = new MyThread();

mt.start();

join线程

(1)【等待其他线程完成】在 A 线程中join BB完成后才能执行 A的后续代码.

(2)【适用的场景】分治法(小线程完成小任务,大线程汇总)。

(3)【示例:分段求和】求和运算。求解一个10000维的数组的和,在Main线程中启动4个线程,每个线程计算2500个数的和,然后Main等待所有结果都出来之后,汇总4个线程的和,求得总和。(注意延迟执行join

后台线程(守护线程)

任务】为其他线程提供服务。

特征:死亡条件】所有前台线程都死亡,后台线程会自动死亡。

典型例子】JVM垃圾回收线程。

用法】在main方法中执行如下代码:

main线程结束后,mt线程才会死亡。

MyThread mt = new MyThread();
mt.setDaemon(true); //设置为守护线程
mt.start();

线程睡眠:sleep(阻塞)

作用】使线程进入阻塞状态,休息一段时间,然后再进入就绪状态,准备继续执行。

用法】在main线程中执行如下代码:

主动】哪个线程调用,哪个线程休息。

Thread.sleep(1000); //main线程休息1秒。

线程让步:yield(就绪)

用法】在main线程中执行如下代码:

Thread.yield(); //使主线程从运行状态 进入 就绪状态。(让调度器处理接下来的工作)

改变线程优先级

设置线程调度的优先级(优先级越高,越容易获得CPU)

用法】在main线程中执行如下代码:

MyThread mt = new MyThread();
mt.setPriority(Thread.MAX_PRIORITY);
mt.start();

线程同步

目的】避免线程安全问题,合理安排线程访问资源的方式(比如:互斥访问)

同步含义】同步指两个或两个以上随时间变化的量在变化过程中保持一定的相对关系。

举例:银行存取款
(1)银行中某账户有1600块钱。
(2)两个客户端(线程)同时取800,过程为:“读取账户钱数 - 更改账户钱数 - 修改账户钱数”。(需要将其原子化
(3)在未同步的情况下,取800的过程是非原子性的,也就是说,两个客户端都可以进入该过程,交替执行可能导致最后两个线程都取到了800,但是账户中仅减少了800(变为0)。

同步代码块

(1)【对象与监视器】java中允许任何对象作为同步监视器。监视器是synchronized得以发挥 作用的重要依据

(2)【监视器选择原则】利用临界资源当同步监视器。

(3)【互斥 + 作用时间】任何时刻只可有一个线程获得对于同步监视器的锁定,同步代码块完成后,线程才会释放对该同步监视器的锁定。

语法格式

//obj一般选临界资源(共享资源)

synchronized(obj){
...
}

同步方法

类的分类

可变类 : 需同步(主要研究对象
不可变类 :不会造成线程安全问题

语法形式:

形式】synchronized + 方法

说明】同步方法与同步代码块比较相似,只不过同步代码块需要显式声明监视器,而同步方法则默认将方法所属的对象作为监视器
示例:

public class Account{
private double balance; //临界资源
...
//【职责分配】将操作资源的方法委托给动作承受者(账户),方便同步。
//【方法访问 + 加锁】线程调用该方法前,需先对该方法所属的对象 加锁。
public synchronized void take_money(double money){...}

public synchronized void save_money(double money){...}
}

同步锁(Lock)

优点:
比synchronized应用更广泛,更灵活。
显式加锁。

学习同步锁,可以与synchronized进行对比学习。

(1)【监视器 & 锁synchronized(共享对象的)监视器 作为 同步检测标志lock将其锁本身作为同步检测标志锁放到对象之中,同样能达到监视器的效果,但是其不仅仅能达到监视器的效果,还能实现更细节的操作。

简化的示例:

(1)【共资源 & 锁 相分离临界资源(balance)负责具体的数据,而锁(lock)负责同步管理

public class Account{
private double balance; //临界资源

private final ReentrantLock lock = new ReentrantLock(); //重入锁

...

public void take_money(double money){

lock.lock();//加锁,其他线程执行时,需检测是否可加。(相当于synchronized的监视器加锁操作)

try{

//取钱操作

} finally {

lock.unlock();//释放锁

}
}

public void save_money(double money){

lock.lock();//【关键】与取钱使用同一个锁,因为取钱,存钱操作同样的资源。

try{

//存钱操作

} finally {

lock.unlock();//释放锁

}
}
}

死锁

线程间互相等待释放锁,谁也不肯让步(抢占式)。

举例
(1)线程A持有lock1,但是需要请求lock2.
(2)线程B持有lock2,但是需要请求lock1.
(3)两者都不肯让步,A请求lock2被阻塞,B请求lock1被阻塞。(永远处于阻塞态)

【破坏死锁的条件】
(见操作系统)

线程通信

前面说了线程间访问共享资源的方式(如互斥访问)以及需要注意的事情。
接下来,我们说说线程间如何进行通信。你将会看到如下内容:

(1)共享资源(内存),可以用于对象间的通信

(2)java线程通信的多种机制(实现)

线程为何要通信?

场景:

(1)【依赖】A线程依赖于B线程处理的数据的结果。(例:消费者需要的产品,由生产者提供)

线程通信1 - Object类的wait()、notify()、notifyAll()

使用synchronized , 由临界资源去通知线程

注意

这3个方法不是Thread类的方法。

适用的场景

(1)synchronized 修饰的同步方法,默认当前对象,可直接调用3个方法。

(2)synchronized 修饰的同步代码块(形式 : synchronized(obj){… }),所以使用时必须使用obj的wait()、notify()、notifyAll() 这3个方法。

主要方法及含义

wait() : 导致当前线程等待(阻塞),直到其他线程调用该同步器(与Object对象关联) 的notify()或者notifyAll()方法来唤醒该线程。
注意:等待通知时,会释放锁。

notify() : 唤醒此同步监视器上等待的单个线程。
(选择具有任意性)
(当前线程放弃锁后,才可以执行被唤醒的线程)

notifyAll() : 唤醒在此同步监视器等待(阻塞)所有线程

示例(语言描述)

(1)两个线程A,B贡献资源obj.

(2)A在执行obj的同步方法M1()时,遇到了obj.wait()方法,于是A进入了阻塞状态。

(2)B在执行obj的同步方法M2()时,执行了obj.notify()方法,于是B执行完M2()后,释放了obj监视器的锁,此时A被唤醒(进入就绪队列)。

线程通信2 - 使用Condition

使用Lock (不存在隐式监视器了), 使用Condition来协调。

锁的位置锁 仍然是放在 共享资源(对象)中

与synchronized类比

Lock : 替代了同步方法 或同步代码块。
Condition : 替代了同步监视器功能。

(1)Condition.await() : 相当于同步监视器的wait().
(2)Condition.signal() : 相当于同步监视器的notify().
(3)Condition.signalAll() : 相当于同步监视器的notifyAll().

代码示例:

public class ThreadCondition {
private final Lock lock = new ReentrantLock(); //负责互斥访问。
private final Condition condition = lock.newCondition(); //负责通知。

public synchronized void saveMoney() throws InterruptedException {
lock.lock();
try {
System.out.println("进入等待之前");
condition.await(); //释放锁
System.out.println("等待结束");
}finally{
lock.unlock();
}
}

public synchronized void takeMoney() throws InterruptedException{
lock.lock();
try{
System.out.println("通知之前");
condition.signalAll(); //唤醒等待(有条件阻塞的)线程,但得等到当前线程执行完毕后。
System.out.println("通知之后");
}finally{
lock.unlock();
}
}
}

线程通信3 - 使用阻塞(BlockingQueue)队列

BlockingQueue主要作用不是作为容器,而是作为线程同步的工具

特征(核心思想):

(1)【生产者】当生产者线程试图向BlockingQueue中放入元素时,若队列已满,则该线程可出现3种情况(异常、返回false,阻塞)

(2)【消费者】当消费者线程试图从BlockingQueue中取出元素时,若队列为空,则线程可出现3种情况(异常、返回false、阻塞)

示例(生产者 - 消费者,抛出异常):

public class ThreadBlockingQueue {
public class Consumer implements Runnable {
BlockingQueue<Integer> bq;

public Consumer(BlockingQueue<Integer> bq) {
this.bq = bq;
}
@Override
public void run() {
for (int i = 0; i < 1;) {

try {
bq.remove();//【关键代码1】可抛出异常,但不阻塞线程
i++; //删除成功才 i++。
System.out.println("删除元素");
} catch (Exception e) {
System.out.println("队列已空");
}
}
}
}

public static void main(String[] args) {
// 创建BlcokingQueue, 限制最多放入元素3个。
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(3);
// 创建消费者
ThreadBlockingQueue tbq = new ThreadBlockingQueue();
Consumer c1 = tbq.new Consumer(blockingQueue);
new Thread(c1).start();

// 主线程生产 整数。
for (int i = 0; i < 4;) {
try {
blockingQueue.add(i);//【关键代码2】可抛出异常,但不阻塞线程
System.out.println(i);
i++;
} catch (Exception e) {
System.out.println("队列已满");
}
}
}
}

相应的一次运行结果

  • remove() 方法不会阻塞线程,只会抛出异常。
  • add() 方法不会阻塞线程,只会抛出异常。

队列已空 【Consumer线程】
队列已空 【Consumer线程】
队列已空 【Consumer线程】
队列已空 【Consumer线程】
队列已空 【Consumer线程】
队列已空 【Consumer线程】
0 【主(Producer)线程】
1 【主(Producer)线程】
2 【主(Producer)线程】
删除元素【Consumer线程】
3 【主(Producer)线程】

示例2(阻塞):

public class ThreadBlockingQueue {
public class Consumer implements Runnable {
BlockingQueue<Integer> bq;

public Consumer(BlockingQueue<Integer> bq) {
this.bq = bq;
}

@Override
public void run() {
for (int i = 0; i < 1;i++) {
try {
System.out.println("消费者线程中");
bq.take();//【关键代码1】取数据时,若队列为空,则阻塞在这里。
System.out.println("取出元素");
} catch (InterruptedException e) {
e.printStackTrace();
}

}
}
}

public static void main(String[] args) throws InterruptedException {
// 创建BlcokingQueue, 限制最多放入元素3个。
BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<>(3);
// 创建消费者
ThreadBlockingQueue tbq = new ThreadBlockingQueue();
Consumer c1 = tbq.new Consumer(blockingQueue);
new Thread(c1).start();

// 主线程生产 整数。
for (int i = 0; i < 4;i++) {
blockingQueue.put(i);//【关键代码2】放数据时,若队列满了,则阻塞在这里。
System.out.println(i);
}
}
}

某次运行的结果

消费者线程中 【Consumer线程】(下一条代码阻塞)
0【主(Producer)线程】
1【主(Producer)线程】
取出元素【Consumer线程】
2【主(Producer)线程】
3【主(Producer)线程】

线程的复用(线程池)

为何要用线程池?

引子

一个线程可以看作一个小的计算机(参考线程的内存模式),我们输入代码(任务)让线程去执行。

过去我们的做法是,线程的任务执行完了,那么线程就可以死亡了。这就好比你用完一次计算机之后(比如说聊天)就把电脑给关了,那么接下来你想看网页就得重新开一台计算机。在现实生活中,很少有人这样做,一般是开一次电脑,做好几件事情

线程池的作用

一个线程可以执行多次任务

任务(代码)任务的执行者(线程)适当分离

线程池框架 - Executors (java5新增)

【Executors】 : 产生线程池的工厂。

【ExecutorService】 : 代表尽快执行的任务的线程池(只要线程池中有空闲线程,就立即执行线程任务)

【ScheduledExecutorService】 : 代表在指定延迟后 或 周期性的执行线程任务的线程池。

示例

public class ThreadExecutors implements Runnable {
@Override
public void run() {
for(int i = 0; i < 50;i++){
System.out.println(Thread.currentThread().getName() + " : " + i);
}
}

public static void main(String[] args) {
//创建有固定线程数量的线程池。
ExecutorService pool = Executors.newFixedThreadPool(6);
//像线程池中提交两个线程任务,并分配两个线程。
pool.submit(new ThreadExecutors());
pool.submit(new ThreadExecutors());
//关闭线程池。
pool.shutdown();
}
}

ForkJoinPool(java7新增)

参考:
【知乎】40个Java多线程问题总结