【Java基础】10.线程并发

《Java基础》目录

例如:第一章 Python 机器学习入门之pandas的使用


提示:八股来自《JavaGuide-并发篇》JavaGuide

文章目录


10. 线程并发

多任务是操作系统的一种能力,可以在同一时间运行多个程序。

多线程程序在更底层扩展了多任务这一概念,单个程序看起来在同时完成多个任务。每个人物在一个线程中执行,线程是控制线程的简称。

多线程的:一个任务可以同时运行多个线程。

10.1 什么是进程?什么是线程?

进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。

Java中,当我们启动main函数时其实就是启动了一个 JVM 的进程,而main函数所在的线程就是这个进程中的一个线程,也称主线程。

线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的方法区资源,但每个线程有自己的程序计数器虚拟机栈本地方法栈,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

并发地运行任务

// 将任务代码放入run方法中
Runnable r = () -> { a = 1;}
// 创建线程
Tread t1 = new Thread(r);
// 启动线程
t1.start();
10.1.1 Thread类方法:
方法说明
Thread(Runnable)构造新线程,并指定目标的run()方法
void start()启动线程并调用run()方法。新线程会立即执行。
void run()调用相关Runnable的run()方法。
static void sleep(long)休眠指定秒数
static void yield()使当前正在执行的线程向另一个线程交出运行权
void join()等待终止指定线程
void join(long)等待指定线程终止或者等待指定毫秒数
void stop()停止该线程(已废弃)
void suspend()暂停这个线程(已废弃)
void resume()恢复线程(只能在suspend调用后使用,已废弃)
void interrupt()·向线程发起中断请求
static boolean interrupted()测试当前线程是否中断,并将其设置为false
boolean isInterrupted()测试线程是否中断
static Thread currentThread()返回当前正在执行的线程
Thread.State.getState()获取这个线程的状态
void setDeamon(boolean)设置线程为守护线程(启动前调用)
void setName(String)设置线程名
static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler)设置默认未捕获异常处理器
static Thread.UncaughtExceptionHandler getUncaughtExceptionHandler()获取默认处理器
void setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler)给线程安装处理器
Thread.UncaughtExceptionHandler getUncaughtExceptionHandler()获取处理器
void setPriority(int)设置线程优先级
static int MIN_PRIORITY最小优先级
static int NORM_PRIORITY默认优先级
static int MAX_PRIORITY最大优先级
10.1.2 Runnable类方法:
方法说明
void run()必须覆盖该方法
10.1.3 UncaughtExceptionHandouler类方法:
方法说明
void uncaughtException(Thread,Throwable)当线程因为一个未捕获异常停止时,记录一个定制报告
10.1.4 ReentrantLock类方法
方法说明
ReentrantLock()构造一个重入锁,用来保护临界区
ReentrantLock(boolean)构造一个采用公平策略的锁,倾向于等待时间最长的线程(严重影响性能)
10.1.5 Lock类方法:
方法说明
void lock()获得这个锁;如果锁被占用则阻塞
void unlock()释放这个锁
Condition newCondition()返回一个与这个锁相关联的条件对象
10.1.6 Condition类方法:
方法说明
void await()将该线程放入条件对象的等待集中
void signalAll()解除该条件等待集中的所有线程
void signal()随机解除解除该条件等待集中的一个线程

10.2 线程状态

线程有6种状态:New(新建),Runnable(可运行),Blocked(阻塞),Waiting(等待),Timed Waiting(计时等待),Terminated(终止)。

在这里插入图片描述

10.2.1 New新建线程

当用new操作符创建一个新线程时,且这个线程还没有运行,即处于新建态

10.2.2 Runnable可运行

一旦调用start方法,线程便处于可运行状态。但也可能没有运行,要由操作系统决定线程具体的运行时间。

任意给定时刻,一个可运行的线程可能正在运行也可能没有运行。

10.2.3 Blocked阻塞态

当一个线程试图获取一个对象锁,而其被另一个线程占用,该线程就会被阻塞。当锁被释放并调度器分配了线程,线程才变成非阻塞态。

10.2.4 Waiting等待态

当一个线程等待另一个线程同志调度器出现一个条件时,就会进入等待态。

10.2.5 Timed Waiting计时等待

当有几个方法有超时参数时,调用这些方法就会另其进入计时等待状态。这个状态会一直保持到超时期满或接收到适当的通知。

10.2.6 Terminated终止状态

线程会因为两个原因而终止:

  1. run方法正常退出,线程终止
  2. 由于一个没有捕获的异常终止了run方法,使线程意外终止

另外可以调用线程的stop方法杀死一个线程。

10.3 线程属性

10.3.1 中断线程

由于stop方法被废弃,其他线程可以通过调用interrupt方法来发起终止线程的请求。

对一个线程调用了interrupt方法时,该线程就进入了中断状态。可以通过Thread.currenThread.isInterrupted()来得到是否设置了中断状态。

每个线程都有一个boolean的属性来表示是否中断状态,通过心跳检查这个标志属性

如果线程处于阻塞状态,便无法检查是否处于中断状态。此时就要引入InterruptedException异常。

中断的线程并不代表其停止。可以通过编写决定线程对于中断的响应。(通常可以把中断请求视为终止请求)

当线程设置了中断,便不要调用sleep方法,否则其不会进入休眠,则是清除中断状态并抛出InterruptedException异常。

interrupted是静态方法,检查中断状态并清除该线程的中断状态,调用后的interrupt会变成false

isInterrupted是实例方法,检查中断状态且不会改变状态

10.3.2 守护线程

可以通过调用setDaemon(true)将一个线程转换为守护线程

守护线程可以为其他线程提供服务。例如,计时器线程、情况过时缓存项线程等。

当只剩下守护线程的时候,虚拟机就会停止运行

10.3.3 线程名

默认情况下,线程有自己的名字Thread-num。可以通过调用setName方法进行设置。

在线程转储时会用到

10.3.4 未捕获异常的处理器

线程的run方法不能抛出任何检查型异常,但是非检查型异常又会导致线程终止。

但是在线程死亡前,异常会传递到一个用于处理未捕获异常的处理器。这个处理器必须实现Thread.UncaughtExceptionHandler接口的类,且只有一个方法void uncaughtException(Thread,Throwable)

可以使用setUncaughtExceptionHandler为任何线程安装处理器,也可以使用Thread的setDefaultUncaughtExceptionHandler为所有线程安装

TheadGroup线程组是可以一起管理的线程的集合。默认情况下所有创建的线程都属于一个组,可以建立其他组。(不建议使用)

10.3.5 线程优先级

Java中每个线程都有自己的优先级,默认情况下会继承构造它的线程的优先级。

可以调用setPriority方法提高或降低一个线程的优先级(MIN_PRIORITY=1,MAX_PRIORITY=10,NORM_PRIORITY=5)

每当线程调度器有机会选择新线程的时候,会优先选择优先级高的线程,但线程优先级高度依赖于系统(Java线程的优先级会优先映射到宿主机的优先级)。

10.4 同步

大多数实际开发场景中,多个线程需要共享同一数据的存取。线程对于同一个对象的修改会由于执行顺序问题进行覆盖,导致对象被破坏,造成数据不一致问题,这种情况通常称为竞态条件

例如:

  1. 时间t1:线程A将数num加载到寄存器
  2. 时间t2:线程A对num进行+1操作;
  3. 时间t3:线程B将线程A抢占,对num进行赋值操作
  4. 时间t4:线程B将赋值后的num写回
  5. 时间t5:线程A被唤醒,完成将num写回,覆盖了线程B的赋值操作
10.4.1 锁对象

有两种机制可以防止并发访问代码块。

  1. 使用synchronized关键字实现;
  2. 使用ReentrantLock类实现;

synchronized关键字会自动提供一个锁以及相关的“条件”,对于大多数需要显示锁的情况非常适用。

ReentrantLock保护代码块的结构如下:

myLock.lock();
try{
    critical section
}finally{
    myLock.unlock();
}

这个结构保证任何时期都只有一个线程进入临界区。一旦一个线程锁定了锁对象,其他线程都无法访问,且其他线程调用了lock时便会暂停,直到第一个线程释放锁。

这种锁称为重入锁,因为线程可以反复获取已拥有的锁。该锁持有一个计数器来跟踪对lock方法的嵌套使用,因此一个锁保护的代码可以调用另一个使用相同锁的方法。

10.4.2 条件对象

通常线程进入临界区后发现必须满足一定条件才能执行,所以可以使用一个条件对象来管理已经获得了锁却不能执行的线程。(条件对象经常被称为条件变量)

使用场景:银行提款

  1. 进程1从账户A提出1000元,账户余额小于1000,进入等待状态(等待另一个进程进行存款从而大于1000元)
  2. 进程2对账户A进行存款操作,由于锁被进程1占用,进入阻塞状态

此时进程1在等待,进程2在阻塞,无法执行,要引入条件对象

一个锁对象可以有一个或多个相关联的条件对象。可以用newCondition方法获得一个条件对象。

name=myLock.newCondition()

当线程发现不满足执行条件时,会调用name.await()方法进行暂停,并释放锁;

一旦有线程调用了await方法就会进入这个条件对象的等待集,当锁可用时该线程并不会进入Runnable态。直到另一个线程调用了name.signalAll方法。这个方法会重新激活等待这个条件的所有线程,并尝试重新进入该对象。

通常await方法应该这样使用:

while(!(满足条件))
    condition.await();

如果进程调用了await方法,并没有进程调用signalAll,该进程就会被永久挂起。

每当条件对象发生了有利方向的改变时,都应该调用signalAll

另外,signal方法是从等待集中随机选择一个线程解除其阻塞态。该方法比signalAll更高效,但却存在不确定的风险,比如一直随到无法执行的线程,导致死锁。

10.4.3 synchronized关键字

锁与条件

  • 锁用来保护代码片段,一次只能有一个线程执行被保护的代码
  • 锁可以管理试图进入被保护代码段的线程
  • 一个锁可以有一个或多个相关联的条件对象
  • 每个条件对象管理那些获得锁但不满足执行条件的线程

LockCondition接口允许程序员完美控制锁,但有时不需要这么操作,可以直接使用Java内置的一种机制,即synchronized关键字。Java每个对象都有一个内置锁,如果一个方法被synchronized声明,则调用它就必须获取这个内置的对象锁。

内部对象锁只有一个关联条件。wait方法将一个线程加入等待集中,notifyAll方法可以解除等待线程的阻塞

notifyAll等价于调用Condition.signalAll

wait等价于调用Condition.await

内部锁和条件存在一些限制

  • 不能总段一个正在尝试获得锁的线程
  • 不能指定尝试获得锁时的超时时间
  • 每个锁仅有一个条件是不够的

编码时优先级

  1. 使用java.util.concurrent包中的机制
  2. 使用synchronized关键字
  3. 使用Lock/Condition机制

锁升级

  1. 无锁

    对于共享资源,不涉及多线程的竞争访问。

  2. 偏向锁

    共享资源首次被访问时,JVM会对该共享资源对象做一些设置,比如将对象头中是否偏向锁标志位置为1,对象头中的线程ID设置为当前线程ID(注意:这里是操作系统的线程ID),后续当前线程再次访问这个共享资源时,会根据偏向锁标识跟线程ID进行比对是否相同,比对成功则直接获取到锁,进入临界区,这也是synchronized锁的可重入功能。

  3. 轻量级锁

    当多个线程同时申请共享资源锁的访问时,这就产生了竞争,JVM会先尝试使用轻量级锁,以CAS方式来获取锁(一般就是自旋加锁,不阻塞线程采用循环等待的方式),成功则获取到锁,状态为轻量级锁,失败(达到一定的自旋次数还未成功)则锁升级到重量级锁。

  4. 重量级锁

    如果共享资源锁已经被某个线程持有,此时是偏向锁状态,未释放锁前,再有其他线程来竞争时,则会升级到重量级锁,另外轻量级锁状态多线程竞争锁时,也会升级到重量级锁,重量级锁由操作系统来实现,所以性能消耗相对较高。

10.4.4 同步块

每个Java对象都有一个锁,线程可以通过调用同步方法来获得锁,除此之外还可以通过进入一个同步块的机制获得锁。

例如:

synchronized(obj){//获得obj的锁
	something...    
}

客户端锁定:使用一个对象的锁来实现额外的原子操作。(脆弱,不推荐)

10.4.5 监视器

锁和条件提供了线程同步的机制,但是却不是面向对象思想的。监视器便实现了这个概念,不要求程序员考虑显示锁便可以保证多线程的安全性。

  • 监视器只是包含私有字段的类
  • 监视器类的每个对象都有一个关联的锁
  • 所有方法都由这个锁锁定(调用了一个对象的方法,这个对象的锁在方法调用时就自动获取并在方法结束时自动释放)
  • 锁可一个有任意多个相关联的条件

Java在以下3中行为中大大削弱了线程的安全性

  • 字段不要求时private
  • 方法不要求时synchronized
  • 内部锁对客户可用
10.4.6 volatile关键字

volatile关键字为实例字段的同步访问提供了一种免锁机制。

如果使用synchronized关键字,当一个方法被调用时,该对象的锁就被获取,所有调用该对象方法的线程都会阻塞。

如:

private boolean a=true;
public synchronized boolean get(){return a;}
public synchronized void set(boolean b){a=b;}

解决这一问题只需要对变量使用一个单独的锁即可,如:

private volatile boolean a = truepublic boolean get(){return a;}
public void set(boolean b){a=b;}

编译器便会插入适当代码使得一个线程对a做了修改便对所有线程可见。

**volatile**无法提供原子性:变量的值变包括:读取->修改->写回三阶段

  • 不能确保翻转字段的值
  • 不能保证读取、翻转、写入不被中断
10.4.7 final变量

除了使用synchronized关键字和volatile关键字还可以将字段声明为final令其可以被安全共享访问。

比如final var accounts=new HashMap<>();只有在构造器完成后才会被线程看到。但是不保证线程安全,有多个线程更改和读取这个映射时仍需要进行同步。

10.4.8 原子性

如果对共享变量除了赋值之外不进行其他操作,那么可以将这些共享变量声明为volatile

java.util.concurrent.atomic包中提供了许多高效的机器码指令。如AtomicInteger中的incrementAndGet和decrementAndGet方法提供了自增和自减。

AtomicInteger,AtomicLong,AtomicIntegerArray,AtomicIntegerFieldUpdater,AtomicLongArray,AtomicLongFieldUpdater,AtomicReference,AtomicReferenceArray,AtomicReferenceFieldUpdater都提供了类似方法。

如果有大量线程访问同一个原子值,会导致性能大幅度下降,因为乐观更新需要太多次重试。

为解决这种问题,可以使用LongAddr和LongAccumulator来解决。他们的原理是用多个变量的总和代表最终的值,而每增加一个线程就会自动增加一个值。(累加操作)

10.4.9 死锁

所有线程都处于阻塞状态,即为死锁。

例如:

  • 账户1:200元
  • 账户2:300元
  • 线程1:账户1转300到账户2
  • 线程2:账户2转400到线程1

都不满足转账条件,进入等待集,陷入死锁。

10.4.10 线程局部变量

避免使用共享变量时,可以使用ThreadLocal辅助类为各个线程提供各自的实例。

方法声明
void get()得到这个线程的当前值
void set(T)为这个线程设置新的值
void remove()删除这个线程的值
static <S> ThreadLoca<S> withInitial(Supplier <? extends S> supplier)场景一个线程局部变量
10.4.11 八股问答
并发和并行的区别
  • 并发:两个及两个以上的作业在同一 时间段 内执行。
  • 并行:两个及两个以上的作业在同一 时刻 执行。
同步和异步的区别
  • 同步:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
  • 异步:调用在发出之后,不用等待返回结果,该调用直接返回。
如何理解线程安全和不安全

线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。

线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。

CPU密集型和I/O密集型

CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。

I/O 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源

创建线程的方式

可以继承Thread类、实现Runnable接口、实现Callable接口、使用线程池、使用CompletableFuture类等等。最终通过new Thread().start()创建。

产生死锁的条件
  • 互斥条件:该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
预防死锁的方法
  • 破坏请求与保持条件:一次性申请所有的资源。
  • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
sleep方法和wait方法

相同:暂停线程的运行

区别:

  • sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。
  • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。sleep()方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout) 超时后线程会自动苏醒。
  • sleep()Thread 类的静态本地方法,wait() 则是 Object 类的本地方法。
为什么wait方法不定义在Thread类中

wait() 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(Object)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(Object)而非当前的线程(Thread)。

sleep() 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。

可以直接调用Thread类的run方法吗

new 一个 Thread,线程进入了新建状态。

调用 start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。

start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

调用 start() 方法方可启动线程并使线程进入就绪状态,直接执行 run() 方法的话不会以多线程的方式执行。

什么是悲观锁

悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

什么是乐观锁

乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

java.util.concurrent.atomic包下面的原子变量类(比如AtomicIntegerLongAdder)就是使用了乐观锁的一种实现方式CAS实现的。

理论上来说:

  • 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如LongAdder),也是可以考虑使用乐观锁的,要视实际情况而定。
  • 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考java.util.concurrent.atomic包下面的原子变量类)。
synchronizedvolatile区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 关键字是线程同步的轻量级实现,所以 volatile性能肯定比synchronized关键字要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块 。
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。
非公平锁和公平锁

公平锁 : 锁被释放之后,先申请的线程先得到锁。性能较差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更频繁。

非公平锁:锁被释放之后,后申请的线程可能会先获取到锁,是随机或者按照其他优先级排序的。性能更好,但可能会导致某些线程永远无法获取到锁。

synchronized ReentrantLock 有什么区别?
  • 两者都是可重入锁。可重入锁 也叫递归锁,指的是线程可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果是不可重入锁的话,就会造成死锁。

  • synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

  • ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

  • ReentrantLocksynchronized增加了一些高级功能:

    • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来指定是否是公平的。
    • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。
可中断锁和不可中断锁

可中断锁:获取锁的过程中可以被中断,不需要一直等到获取锁之后 才能进行其他逻辑处理。ReentrantLock 就属于是可中断锁。

不可中断锁:一旦线程申请了锁,就只能等到拿到锁以后才能进行其他的逻辑处理。 synchronized 就属于是不可中断锁。

共享锁和独占锁有什么区别
  • 共享锁:一把锁可以被多个线程同时获得。
  • 独占锁:一把锁只能被一个线程获得。
线程有读锁还能获取写锁吗

在线程持有读锁的情况下,该线程不能取得写锁(因为获取写锁的时候,如果发现当前的读锁被占用,就马上获取失败,不管读锁是不是被当前线程持有)。

在线程持有写锁的情况下,该线程可以继续获取读锁(获取读锁时如果发现写锁被占用,只有写锁没有被当前线程占用的情况才会获取失败)。

读锁为什么不能升级为写锁

写锁可以降级为读锁,但是读锁却不能升级为写锁。这是因为读锁升级为写锁会引起线程的争夺,毕竟写锁属于是独占锁,这样的话,会影响性能。

另外,还可能会有死锁问题发生。举个例子:假设两个线程的读锁都想升级写锁,则需要对方都释放自己锁,而双方都不释放,就会产生死锁。

10.5 线程安全的集合

多个线程要并发地修改一个数据结构,很容易破坏这个数据结构。可以通过提供锁来保护共享的数据结构,但选择线程安全的实现可能更为容易。

10.5.1 阻塞队列

很多线程问题都可以使用一个或多个队列来描述。生产者线程向队列插入元素,消费者线程获取元素。

使用队列可以安全的从一个线程向另一个线程传递数据。

当试图向队列条件元素而队列已满,或想从队列移出元素而队列已空的时候,阻塞队列将导致线程阻塞。协调多个线程之间的合作时,阻塞队列是一个有用的工具。(周期性的将中间结果存储在阻塞队列中,其他线程移除中间结果并进一步修改)

阻塞队列的三类方法:

抛出异常

方法说明
add添加一个元素,队列满则抛出IllegalStateException
element返回队头元素,队列空则抛出NoSuchElementException
remove移除并返回队头元素,队空则抛出NoSuchElementException

返回值

方法说明
offer添加一个元素并返回true,队满则返回false
peek返回队头元素,队空则返回null
poll移除并返回队头元素,队空则返回null

阻塞

方法说明
put添加一个元素,队满则阻塞
take移除并返回队头元素,队空则阻塞
  • ArrayBlockingQueue:有指定容量和公平性设置的阻塞队列,实现为循环数组
  • LinkedBlockingQueue:无上限的阻塞队列或双向队列,实现为一个链表
  • DelayQueue:包含elayed元素的无上限阻塞队列,只有延迟已经到期的元素可以从队列中移除
  • PriorityBlockingQueue:无上限阻塞优先队列,实现为一个堆,默认初始容量为11,没有指定比较器则必须实现Comparable接口
10.5.2 映射、集合、队列

java.util.concurrent包提供了映射、有序集合、队列的高效实现:

  • ConcurrentHashMap:构造一个可以被多线程安全访问的散列映射表
  • ConcurrentSkipListMap:构造一个可以被多线程安全访问的有序映像
  • ConcurrentSkipListSet:构造一个可以被多线程安全访问的有序集
  • ConcurrentLinkedQueue:构造一个可以被多线程安全访问的无上限非阻塞队列

这些集合确定大小通常需要遍历

10.5.5 较早的线程安全集合

Vector和Hashtable提供了动态数组和散列表的线程安全的实现,但逐渐被ArrayList和HashMap替代。但这些类不是线程安全的。

任何类都可以通过使用synchronized实现线程安全

List<E> synchArrayLIst=Collections.synchronizedList(new ArrayList<>());
Map<K,V> synchHashMap=Collections.synchronizedMap(new HashMap<>());

10.6 线程池

构建一个新的线程的开销很大,因为涉及到了频繁与操作系统进行交互。为了解决这一问题,引入了线程池的概念。

线程池中包含了许多准备运行的线程,为线程准备一个Runnable,就会有一个线程调用run方法。

run方法执行结束后,线程不会死亡,而是留在线程池中准备为下一个请求提供服务。

10.6.1 Callable与Future

Runnable封装一个异步运行的任务,没有参数与返回值,Callable是带有返回值的,只有一个方法call

Callable返回的是其的参数类型的数据类型的结果值。

Future保存异步计算的结果。可以启动一个计算,将Future对象交给线程,这个对象的所有者在结果计算好后就可以获得结果。

FutureTask构造一个既是Future又是Runnable的对象。

Future<V>方法说明
V get()会阻塞,直到结果计算完成
void cancel(boolean)取消计算
boolean isCancelled任务完成前被取消返回true
boolean isDone任务完成返回true
10.6.2 执行器

执行器Executors具有许多静态方法,可以用来构造线程池。

方法声明
newCachedThreadPool创建一个可缓存的线程池。适用于执行大量的短期异步任务。
newFixedThreadPool(int nThreads)创建一个固定大小的线程池。线程数量是固定的,不会自动扩展。适用于执行固定数量的长期任务。
newSingleThreadExecutor创建一个单线程的线程池。这个线程池中只包含一个线程,用于串行执行任务。适用于需要按顺序执行任务的场景。
newScheduledThreadPool(int corePoolSize)创建一个固定大小的线程池,用于定时执行任务。线程数量固定,不会自动扩展。适用于定时执行任务的场景。
newSingleThreadScheduledExecutor创建一个单线程的定时执行线程池。只包含一个线程,用于串行定时执行任务。
newWorkStealingPool(int parallelism)创建一个工作窃取线程池,线程数量根据CPU核心数动态调整。适用于CPU密集型的任务。

使用线程池时所作的工作

  1. 调用Executors类的静态方法newCachedThreadPool或newFixedThreadPool
  2. 调用submit提交Runnable或Callable对象
  3. 保存好返回的Future对象,以便得到结果或取消任务
  4. 当不想再提交任务时,调用shutdown
10.6.3 线程池类型

CachedThreadPool–可缓存线程池

必要时创建新的线程,当空闲线程超过60秒时会被终止。

特点:

  • 线程的创建数量几乎没有限制。这样可灵活的往线程池中添加线程。
  • 如果线程空闲超过了60秒,则该工作线程将自动终止。终止后提交了新的任务,则线程池重新创建一个工作线程
  • 要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪

FixedThreadPool–固定线程数量

池中包含固定数目的线程;空闲线程会一直保留

特点:

  • 提高程序效率和节省创建线程时所耗的开销

缺点:

  • 线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源

SingleThreadExecutor–单线程池

只有一个线程的池,会顺序执行提交的任务

ScheduleThreadPool–定时线程池

支持定时及周期性任务执行,用于调度执行的线程池。

WorkStealingPool

SingleThreadScheduledExecutor

10.6.4 线程池常见参数

corePoolSize——核心线程最大数

maximumPoolSize——线程池最大线程数

keepAliveTime——空闲线程存活时间

  • 当一个非核心线程被创建,使用完归还给线程池
  • 一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定时间后,这个空闲线程会被销毁,这里的指定时间由keepAliveTime来设定

unit——空闲线程存活时间单位

  • 这是keepAliveTime的计量单位

workQueue——等待队列

  • 当线程池满了,线程就会放入这个队列中。任务调度再取出

  • jdk提供四种工作队列

    • ArrayBlockingQueue——基于数组的阻塞队列,按FIFO,新任务放队尾

      • 有界的数组可以防止资源耗尽问题。

      • 当线程池中线程数量达到corePoolSize后,再有新任务进来,则会将任务放入该队列的队尾,等待被调度。

      • 如果队列已经是满的,则创建一个新线程,

      • 如果线程数量已经达到maxPoolSize,则会执行拒绝策略。

    • LinkedBlockingQuene——基于链表的无界阻塞队列,按照FIFO排序

      • 其实最大容量为Interger.MAX

      • 由于该队列的近似无界性,当线程池中线程数量达到corePoolSize后,再有新任务进来,会一直存入该队列,而不会去创建新线程直到maxPoolSize,因此使用该工作队列时,参数maxPoolSize其实是不起作用的

    • SynchronousQuene——不缓存任务的阻塞队列

      • 生产者放入一个任务必须等到消费者取出这个任务。

      • 也就是说新任务进来时,不会缓存,而是直接被调度执行该任务,

      • 如果没有可用线程,则创建新线程,如果线程数量达到maxPoolSize,则执行拒绝策略

    • PriorityBlockingQueue——具有优先级的无界阻塞队列

      • 优先级通过参数Comparator实现。

threadFactory–线程工厂

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

handler——拒绝策略

  • 当工作队列中的任务已到达最大限制,并且线程池中的线程数量也达到最大限制,这时如果有新任务提交进来,就用到拒绝策略

  • jdk中提供了4中拒绝策略

    • CallerRunsPolicy——主线程自己执行该任务

      • 该策略下,在调用者线程中直接执行被拒绝任务的run方法,除非线程池已经shutdown,否则直接抛弃任务。
    • AbortPolicy——抛出异常

      • 该策略下,直接丢弃任务,并抛出RejectedExecutionException异常
    • DiscardPolicy——直接丢弃

      • 该策略下,直接丢弃任务,什么都不做
    • DiscardOldestPolicy——早删晚进

      • 该策略下,抛弃进入队列最早的那个任务,然后尝试把这次拒绝的任务放入队列

线程池工作流程

  • 线程池刚创建时,里面没有一个线程。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
  • 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
    • 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
    • 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
    • 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
    • 如果队列满了,而且正在运行的线程数量等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
  • 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  • 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于 corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。
10.6.5 八股问答
如何创建线程池
  1. 通过ThreadPoolExecutor构造函数创建(推荐)
  2. 通过Executor框架的工具类Executors创建
为什么不推荐使用内置线程池

《阿里巴巴 Java 开发手册》明确指出线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源开销,解决资源不足的问题。如果不使用线程池,有可能会造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

线程池的包和策略有哪些
  • ThreadPoolExecutor.AbortPolicy:抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy:调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy:不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy:此策略将丢弃最早的未处理的任务请求。
线程池常用的阻塞队列
  • 容量为 Integer.MAX_VALUELinkedBlockingQueue(无界队列):FixedThreadPoolSingleThreadExectorFixedThreadPool最多只能创建核心线程数的线程(核心线程数和最大线程数相等),SingleThreadExector只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
  • SynchronousQueue(同步队列):CachedThreadPoolSynchronousQueue 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,CachedThreadPool 的最大线程数是 Integer.MAX_VALUE ,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
  • DelayedWorkQueue(延迟阻塞队列):ScheduledThreadPoolSingleThreadScheduledExecutorDelayedWorkQueue 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。DelayedWorkQueue 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 Integer.MAX_VALUE,所以最多只能创建核心线程数的线程。
线程池工作流程

在这里插入图片描述

  1. 如果当前运行的线程数小于核心线程数,那么就会新建一个线程来执行任务。
  2. 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数,那么就把该任务放入到任务队列里等待执行。
  3. 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个线程来执行任务。
  4. 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用RejectedExecutionHandler.rejectedExecution()方法。
如何设置线程池大小
  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
  • 如何判断是 CPU 密集任务还是 IO 密集任务
    • CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
Future类作用

Future 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 Future 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了

  • 取消任务;
  • 判断任务是否被取消;
  • 判断任务是否已经执行完成;
  • 获取任务执行结果。

10.7 进程

上述都在讲述如何在一个程序中的不同线程中执行Java代码,但有时还需要执行另一个程序。因此可以使用ProcessBuilder和Process类。

Process类在一个单独的操作系统进程中执行一个命令,允许我们与标准输入、输出和错误流交互。

ProcessBuilder允许我们配置Process对象。

10.7.1 创建一个进程

使用ProcessBuilder创建一个进程。

var processBuilder = new ProcessBuilder("gcc","main.cpp");

传入参数的第一个字符串必须是一个可执行的命令,而不是shell指令。

每个进程都有一个工作目录,默认为Java程序的目录,可以通过调用directory方法改变工作目录。

10.7.2 运行一个进程

配置了一个构造器之后,调用它的start方法启动进程。

等待进程完成后,可以调用waitFor方法获取返回过程中的退出值。如果进程没有超时,第二个调用返回true。

10.7.3 进程句柄

要获得程序启动的一个进程的更多信息,可以使用ProcessHandle接口。通过以下方式得到ProcessHandle

  1. 给定一个Process对象,用toHandle方法生成他的ProcessHandle
  2. 给定一个long类型的操作系统进程ID,通过ProcessHandle.of(id)生成这个进程的句柄
  3. Process.current()时运行这个Java虚拟机的进程的句柄
  4. ProcessHandle.allProcess()可以生成对当前进程可见的所有操作系统进程的Stream\<ProcessHandle\>
  • 13
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值