1. 进程 | 线程、并行 | 并发
- 软件(Software):一系列按照特定顺序组织的计算机数据和指令的集合。有系统软件和应用软件之分。
- 程序(Program):为完成特定任务、用某种语言编写的一组指令的集合,指一段静态的代码,是静态对象。
- 进程(Process):程序的一次执行过程,或是正在运行的一个程序,是一个动态的过程。进程是操作系统资源分配的基本单位。
- 线程(Thread):一个程序内部的一条执行路径。线程是处理器任务调度和执行的基本单位(最小单元)。
- 并行(Parallel):多个CPU同时执行多个任务。两个或多个事件在同一时刻发生。
- 并发(Concurrent):单个CPU(采用时间片)同时执行多个任务。两个或多个事件在同一时间段内发生。
Java程序是运行在JVM上面的,每一个JVM其实就是一个进程。所有的资源分配都是基于JVM进程来的。而在这个JVM进程中,又可以创建出很多线程,多个线程之间共享JVM资源,并且多个线程可以并发执行。
每个线程拥有独立的运行栈和程序计数器,线程切换的开销小。一个进程中的多个线程共享相同的内存单元/内存地址空间,它们从同一堆中分配对象,可以访问相同的变量和对象,这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全隐患(线程安全问题)。在JVM中,Java堆和方法区的区域是多个线程共享的数据区域,多个线程可以操作保存在堆或者方法区中的同一个数据,保存在堆和方法区中的变量就是Java中的共享变量。
- 类变量:存放在JVM的方法区中
- 成员变量:存放在JVM的堆内存中
- 局部变量:存放在JVM的栈内存中
进程和线程的区别
- 进程是一个独立的运行环境,而线程是在进程中执行的一个任务。他们两个本质的区别是是否单独占有内存地址空间及其它系统资源(比如I/O)。
- 系统在运行时会为每个进程分配不同的内存区域,进程单独占有一定的内存地址空间,所以进程间存在内存隔离,数据是分开的,数据共享复杂但是同步简单,各个进程之间互不干扰;而线程共享所属进程占有的内存地址空间和资源,数据共享简单,但是同步复杂。
- 进程单独占有一定的内存地址空间,一个进程出现问题不会影响其他进程,不影响主程序的稳定性,可靠性高;一个线程崩溃可能影响整个程序的稳定性,可靠性较低。
- 进程单独占有一定的内存地址空间,进程的创建和销毁不仅需要保存寄存器和栈信息,还需要资源的分配回收以及页调度,开销较大;线程只需要保存寄存器和栈信息,开销较小。
- 进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单位,即CPU分配时间的单位 。
多进程的方式也可以实现并发,为什么我们要使用多线程?
- 进程间的通信比较复杂,而线程间的通信比较简单,通常情况下,我们需要使用共享资源,这些资源在线程间的通信比较容易。
- 进程是重量级的,而线程是轻量级的,故多线程方式的系统开销更小。
进程调度算法:
- 先到先服务
- 短作业优先
- 时间片轮转
- 优先级调度
- 多级反馈队列(既能使高优先级的作业得到响应,又能使短作业进程迅速完成。)
Java的线程调度方法:
- 同优先级线程组成先进先出队列(先到先服务),使用时间片策略;
- 高优先级的线程的调度策略采用抢占式,优先级高的线程比优先级低的线程会有更大的几率优先执行。
对于单CPU的计算机来说,在任意时刻只能执行一条机器指令,每个线程只有获得CPU的使用权才能执行指令;所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得CPU的使用权,分别执行各自的任务。线程的运行状态中包含两种子状态,即就绪(READY)和运行中(RUNNING),而一个线程想要从就绪状态变成运行中状态,这个过程需要系统调度,即给线程分配CPU的使用权,获得CPU使用权的线程才会从就绪状态变成运行状态。给多个线程按照特定的机制分配CPU的使用权的过程就叫做线程调度。
守护线程 VS 用户线程
- 它们几乎在每个方面都是相同的,唯一的区别是判断 JVM 何时离开。
- 守护线程是用来服务用户线程的,通过在 start() 方法前调用 thread.setDaemon(true) 可以把一个用户线程变成一个守护线程。
- 在 Daemon 线程中产生的新线程也是 Daemon 线程。
- Java 垃圾回收就是一个典型的守护线程。
- 若 JVM 中都是守护线程,当前 JVM 将退出。JVM 退出时守护线程中的 finally 块并不一定会执行。
单核CPU,其实是一种假的多线程,因为在一个时间单元内,也只能执行一个线程的任务。但是因为CPU时间单元特别短,因此感觉不出来。如果是多核CPU的话,才能更好的发挥多线程的效率。以单核CPU为例,只使用单个线程先后完成多个任务(调用多个方法),肯定比用多个线程来完成用的时间更短,为何仍需多线程呢?
- 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
- 提高计算机系统CPU的利用率。
- 改善程序结构,将既长又复杂的进程分为多个线程,独立运行,利于理解和修改。
何时需要多线程?
- 程序需要同时执行两个或多个任务。
- 程序需要实现一些需要等待的任务时,如:用户输入、文件读写操作、网络操作、搜索等。
- 需要一些后台运行的程序时。
- 一个 Java 应用程序 java.exe,至少有三个线程:main() 主线程、gc() 垃圾回收线程、异常处理线程。
- 使用多线程的原因:更多的处理器核心、更快的响应时间、更好的编程模型。
package com.thread.java;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
/**
* @author rrqstart
* @Description 一个普通的Java程序包含的线程
*/
public class MultiThread {
public static void main(String[] args) {
//获取Java线程管理MXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
//不需要获取同步的monitor和synchronizer信息,仅获取线程和线程堆栈信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
//遍历线程信息,仅打印线程ID和线程名称
for (ThreadInfo threadInfo : threadInfos) {
System.out.println(threadInfo.getThreadId() + " : " + threadInfo.getThreadName());
}
}
}
//程序执行结果:
6 : Monitor Ctrl-Break //监控Ctrl-Break中断信号
5 : Attach Listener //该线程用于JVM进程间的通信,但是它不一定会启动
4 : Signal Dispatcher //分发处理发送给JVM信号的线程
3 : Finalizer //调用对象的finalize方法的线程
2 : Reference Handler //清除Reference的线程
1 : main //main线程,用户程序的入口
2. 线程的创建和使用
Java 语言的 JVM 允许程序运行多个线程,它通过 java.lang.Thread 类来体现。
package java.lang; //since JDK1.0
public class Thread implements Runnable {
private Runnable target;
public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}
private void init(ThreadGroup g, Runnable target, String name, long stackSize) {
init(g, target, name, stackSize, null, true);
}
private void init(ThreadGroup g, Runnable target, String name, long stackSize, AccessControlContext acc, boolean inheritThreadLocals) {
//......
this.target = target;
//......
}
@Override
public void run() {
if (target != null) {
target.run();
}
}
//Java thread status for tools, initialized to indicate thread not yet started.
private volatile int threadStatus = 0;
public synchronized void start() {
if (threadStatus != 0) //A zero status value corresponds to state "NEW".
throw new IllegalThreadStateException();
//......
start0();
//......
}
private native void start0();
}
2.1 继承Thread类
要想实现多线程,必须在主线程中创建新的线程对象,Java 语言使用 Thread 类及其子类的对象来表示线程。每个线程都是通过某个特定 Thread 对象的 run() 方法来完成操作的,经常把 run() 方法的主体称为线程体。通过该 Thread 对象的 start() 方法来启动这个线程,而非直接调用 run() 方法。
- 创建一个继承于
java.lang.Thread
类的子类; - 子类重写 Thread 类的 run() 方法,将此线程需要执行的操作声明在 run() 方法中;
- 创建 Thread 类的子类的对象,即创建线程对象;
- 通过此线程对象调用 start() 方法:启动当前线程;调用当前线程的 run() 方法。
- 在主线程中,调用了子线程的 start() 方法后,主线程无需等待子线程的执行,即可执行后续的代码。而子线程便会开始执行其 run() 方法。
- 如果在 main() 中自己手动调用 run() 方法,主线程就需要等待其执行完,那么就只是普通方法调用,没有启动多线程模式。想要启动多线程,必须调用 start() 方法。
- run() 方法由 JVM 调用,什么时候调用,执行的过程控制都由操作系统的 CPU 调度决定。
- 我们在程序里面调用了 start() 方法后,虚拟机会先为我们创建一个线程,然后等到这个线程第一次得到时间片时再调用 run() 方法。
- 一个线程对象只能调用一次 start() 方法启动线程,如果连续两次调用同一个线程对象的 start() 方法,会报 IllegalThreadStateException,我们需要重新创建一个线程对象来完成再启动一个新线程的任务。
- 每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,方法 run() 称为线程体。
Thread类中的构造器:
- Thread():创建新的Thread对象;
- Thread(String threadname):创建线程并指定线程实例名;
- Thread(Runnable target):指定创建线程的目标对象,它实现了Runnable接口中的run方法;
- Thread(Runnable target, String name):创建新的Thread对象。
Thread类中的常用方法:
- void start():启动当前线程;调用当前线程的run()方法。
- run():通常需要重写Thread类中的此方法,将创建的线程在被调度时执行的操作声明在此方法中。
- static Thread currentThread():静态方法,返回执行当前代码的线程。在Thread子类中就是this,通常用于主线程和Runnable实现类。
- String getName():获取当前线程的名字。
- void setName(String name):设置当前线程的名字。
- static void yield():释放当前cpu的执行权,线程让步。暂停当前正在执行的线程,把执行机会让给优先级相同或更高的线程,若队列中没有同优先级的线程,忽略此方法。
- join():在线程a中调用线程b的join()方法,此时线程a就进入“等待”状态,直到线程b完全执行完以后,再继续执行a线程。join()方法及其重载方法底层都是利用了wait(long)这个方法。
- static void sleep(long millis):让当前线程“睡眠”指定的millis毫秒。令当前活动线程在指定时间段内放弃对CPU的控制,使其他线程有机会被执行,时间到后重排队。该方法会抛出 InterruptedException 异常。
- boolean isAlive():返回boolean类型,判断当前线程是否存活。
stop(): 强制线程生命期结束,不推荐使用。- getPriority():获取线程的优先级。线程创建时继承父线程的优先级,低优先级只是获得调度的概率低,并非一定是在高优先级线程之后才被调用。
- setPriority(int newPriority):设置线程的优先级(MIN _PRIORITY:1、MAX_PRIORITY:10、NORM_PRIORITY:5(默认优先级))。
- setDaemon(true):将一个线程转换为守护线程,在线程启动前调用。
- isDaemon():检查当前线程是否是守护线程。
- getState():返回此线程的状态。
- public void interrupt():中断线程,这里的中断线程并不会立即停止线程,而是设置线程的中断状态为 true(默认是flase)。
- public boolean isInterrupted():检查线程中断状态,但不改变线程的中断状态。如果该线程已被中断,返回true,否则返回false。
- public static boolean interrupted():测试当前线程是否被中断。线程的中断状态受这个方法的影响,调用一次使线程中断状态设置为true,连续调用两次会使得这个线程的中断状态重新转为false。如果当前线程已中断,则返回true,否则返回false。
线程中断
- 在某些情况下,我们在线程启动后发现并不需要它继续执行下去时,需要中断线程。目前在Java里还没有安全直接的方法来停止线程,但是Java提供了线程中断机制来处理需要中断线程的情况。
- 线程中断机制是一种协作机制,通过中断操作并不能直接终止一个线程,而是通知需要被中断的线程自行处理。
- 在线程中断机制里,当其他线程通知需要被中断的线程后,线程中断的状态被设置为true,但是具体被要求中断的线程要怎么处理,完全由被中断线程自己而定,可以在合适的时机处理中断请求,也可以完全不处理继续执行下去。
2.2 实现Runnable接口
package java.lang; //since JDK1.0
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
- 创建一个实现了 Runnable 接口的类。
- 实现类去实现 Runnable 接口中的抽象方法 run()。
- 创建实现类的对象。
- 将此对象作为实际参数传递到 Thread 类的构造器中,创建 Thread 类的线程对象。
- 通过 Thread 类的对象调用 start() 方法,开启线程,并调用 Runnable 实现类中的 run() 方法。
继承方式和实现方式的联系与区别:
- Thread 类也是 Runnable 接口的实现类。
- 继承 Thread 类:线程代码存放在 Thread 子类 run 方法中。
- 实现 Runnable 接口:线程代码存在接口的实现类的 run 方法中。
- 实现方式的好处:避免了单继承的局限性;多个线程可以共享同一个接口实现类的对象,非常适合多个相同线程来处理同一份资源。
- 注意:Runnable 接口的出现,降低了线程对象和线程任务的耦合性。
2.3 实现Callable接口
以上两种创建线程的方式,其实是有一个缺点的:在执行完任务之后无法获取执行结果。如果我们希望在主线程中得到子线程的执行结果的话,就需要用到 Callable 和 FutureTask。
- JDK1.5 之前创建线程有两种方法:继承 Thread 类、实现 Runnable 接口。
- JDK5.0 新增的线程创建方式:实现 Callable 接口、线程池。
package java.util.concurrent; //since JDK1.5
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
- 创建一个实现 Callable 的实现类。
- 实现 call() 方法,将此线程需要执行的操作声明在 call() 中。
- 创建 Callable 接口实现类的对象。
- 将此 Callable 接口实现类的对象作为参数传递到 FutureTask 构造器中创建 FutureTask 对象 。
- 将 FutureTask 的对象作为参数传递到 Thread 类的构造器中创建 Thread 对象,并调用 start()。
- 在主线程中利用 get() 方法获取 Callable 中 call() 方法的返回值。
注意:调用 get() 方法会阻塞当前线程,一直等子线程执行完并返回后才能继续执行当前线程后面的代码,所以实际编码中建议使用可以设置超时时间的重载 get() 方法。 一般在 Callable 执行完之前的这段时间,主线程可以先去做一些其他的事情,事情都做完之后,再获取 Callable 的返回结果,可以通过 isDone() 方法来判断子线程是否执行完。一般我们会把 Callable 放到线程池中,然后让线程池去执行 Callable 中的代码。
如何理解实现 Callable 接口的方式创建多线程比实现 Runnable 接口创建多线程方式强大?
- call() 方法可以有返回值(支持泛型的返回值),借助 FutureTask 类获取返回结果。
- call() 方法可以抛出异常,被外面的操作捕获,获取异常的信息。
FutureTask 可用于异步获取执行结果或取消执行任务的场景。通过传入 Callable 的任务给FutureTask,直接调用其 run方法或者放入线程池执行,之后可以在外部通过 FutureTask 的 get方法异步获取执行结果,因此,FutureTask 非常适合用于耗时的计算,主线程可以在完成自己的任务后,再去获取结果。另外,FutureTask 还可以确保即使调用了多次 run方法,它都只会执行一次 Runnable 或者 Callable 任务,或者通过 cancel方法取消 FutureTask 的执行等。
Future 接口:
- 可以对具体 Runnable、Callable 任务的执行结果进行取消、查询是否完成、获取结果等。
- FutrueTask 是 Futrue 接口的唯一的实现类。
- FutureTask 同时实现了 Runnable、Future 接口。它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值。
2.4 线程池
3. 线程的同步
线程安全是编程中的术语,指某个函数、函数库在并发环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
多线程操作共享数据(多个线程共同操作的变量)出现了安全问题:
- 问题的原因:当多条语句在操作同一个线程共享数据时,一个线程对多条语句只执行了一部分,还没有执行完,另一个线程参与进来执行,导致共享数据的错误。
- 解决办法:对多条操作共享数据的语句,只能让一个线程都执行完,在执行过程中,其他线程不可以参与执行。
- Java 对于多线程的安全问题提供了专业的解决方式:同步机制。同步代码块 + 同步方法
- 同步监视器:同步锁,任何一个类的对象都可以充当锁,要求多个线程必须要共用同一把锁。
//1.同步代码块
synchronized (同步监视器){
//需要被同步的代码:操作共享数据的代码
}
//2.同步方法
public synchronized void show (String name) {
}
synchronized 的锁是什么?
- 任意对象都可以作为同步锁。所有对象都自动含有单一的锁(监视器)。
- 同步方法的锁:静态方法(类名.class),非静态方法(this)。
- 同步代码块的锁:自己指定,很多时候也是指定为 this 或 类名.class。
- 注意:必须确保使用同一个资源的多个线程共用同一把锁,否则就无法保证共享资源的安全。
- 一个线程类中的所有静态方法共用同一把锁(类名.class),所有非静态方法共用同一把锁(this),同步代码块(指定需谨慎)。
释放锁的操作:
- 当前线程的同步方法、同步代码块执行结束。
- 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行。
- 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致异常结束。
- 当前线程在同步代码块、同步方法中执行了线程对象的
wait()
方法,当前线程暂停,并释放锁。
不会释放锁的操作:
- 线程执行同步代码块或同步方法时,程序调用
Thread.sleep()
、Thread.yield()
方法暂停当前线程的执行。- 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放锁(同步监视器)。应尽量避免使用suspend()和resume()来控制线程。
线程的死锁问题:
- 不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。
- 出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。
从JDK 5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步。同步锁使用 Lock 对象充当。
java.util.concurrent.locks.Lock
接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象。ReentrantLock 类实现了 Lock ,它拥有与 synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是 ReentrantLock,可以显式加锁、释放锁。面试题:synchronized 与 Lock 的异同?
- 同:二者都可以解决线程安全问题。
- 异:synchronized是隐式锁,在执行完相应的同步代码以后,自动的释放同步监视器,即出了作用域自动释放锁。Lock是显式锁,需要手动的启动同步(lock),同时结束同步也需要手动的实现(unlock)。Lock只有代码块锁,synchronized有代码块锁和方法锁。使用Lock锁,JVM将花费较少的时间来调度线程,性能更好,具有更好的扩展性(提供更多的子类)。
- 优先使用顺序:Lock → 同步代码块(已经进入了方法体,分配了相应资源)→ 同步方法(在方法体之外)
class A {
private final ReentrantLock lock = new ReenTrantLock();
public void m() {
lock.lock();
try{
//保证线程安全的代码
} finally {
lock.unlock();
}
}
}
4. 线程的通信
- 线程通信:wait() / notify() / notifyAll() → 定义在Object类中。
- wait():一旦执行此方法,当前线程就进入WAITING状态,当前线程挂起并放弃CPU、同步资源,并释放同步监视器,使别的线程可访问并修改共享资源。当前线程排队等待其他线程调用notify()或notifyAll()方法唤醒,唤醒后等待重新获得对监视器的所有权后才能继续执行。
- notify():一旦执行此方法,就会唤醒被wait的一个线程。如果有多个线程被wait,就唤醒优先级高的那个。
- notifyAll():一旦执行此方法,就会唤醒唤醒正在排队等待资源的所有线程结束等待。
- 这三个方法只有在synchronized同步方法或synchronized同步代码块中才能使用,否则会报 java.lang.IllegalMonitorStateException异常(非法的监视器状态异常)。
- 这三个方法的调用者必须是同步代码块或同步方法中的同步监视器(锁对象),而任意对象都可以作为synchronized的同步锁, 因此这三个方法只能在Object类中声明。
//在当前线程中调用wait()方法:锁对象.wait()
public final void wait() throws InterruptedException { wait(0); }
public final native void wait(long timeout) throws InterruptedException;
//在当前线程中调用notify()方法:锁对象.notify()
public final native void notify();
public final native void notifyAll();
面试题:sleep() 和 wait() 的异同?
- 同:一旦执行方法,都可以使得当前的线程进入“等待”状态。
- ① 声明的位置不同:Thread 类中声明 sleep(),Object 类中声明 wait()。
- ② 调用的要求不同:sleep() 可以在任何需要的场景下调用,wait() 必须使用在同步代码块或同步方法中。
- ③ 关于是否释放同步监视器:如果两个方法都使用在同步代码块或同步方法中,sleep() 不会释放锁,wait() 会释放锁。
- ④ wait() 可以指定时间,也可以不指定;而 sleep() 必须指定时间。
- ⑤ wait() 释放 cpu 资源,同时释放锁;sleep() 释放cpu资源,但是不释放锁,所以易死锁。
5. 线程的状态
要想实现多线程,必须在主线程中创建新的线程对象。Java语言使用Thread类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态。
- 新建:当一个 Thread 类或其子类的对象被声明并创建时,新生的线程对象处于新建状态。
- 就绪:处于新建状态的线程被 start() 后,将进入就绪队列等待 CPU 时间片,此时它已具备了运行的条件,只是没分配到 CPU 资源。
- 运行:当就绪的线程被调度并获得 CPU 资源时便进入运行状态,run() 方法定义了线程的操作和功能。
- 阻塞:在执行进程的过程中,可能遇到某些阻塞的动作,比如I/O操作,处理器如果一直等待该阻塞动作完成的话就太浪费时间了,所以会把等待阻塞动作完成的进程放到一个阻塞队列里,之后并不会从这个队列里挑选即将执行的进程,而是直到该阻塞动作完成,才重新把该进程放到就绪队列里等待执行。
- 死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束。
package java.lang;
public class Thread implements Runnable {
//A thread can be in only one state at a given point in time.
//These states are virtual machine states which do not reflect any operating system thread states.
//内部枚举类:6种线程状态
public enum State {
//A thread that has not yet started is in this state.
NEW, //新建,还没有调用start()方法.
//A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.
RUNNABLE, //可运行,Java线程将操作系统中的就绪和运行两种状态统称为可运行状态.
//A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling Object#wait().
BLOCKED, //阻塞,线程阻塞在进入synchronized关键字修饰的方法或代码块时,而阻塞在Lock接口的线程处于等待状态,因为Lock接口对于阻塞的实现使用了LockSupport类中的相关方法.
//A thread in the waiting state is waiting for another thread to perform a particular action.
WAITING, //等待,表示当前线程需要等待其他线程做出一些特定动作(通知或中断).
//Thread state for a waiting thread with a specified waiting time.
TIMED_WAITING, //计时等待,线程等待一个具体的时间,时间到后会被自动唤醒.
//Thread state for a terminated thread. The thread has completed execution.
TERMINATED; //终止,run方法正常退出或run方法遇到没有捕获的异常.
}
}
关于 start() 引申的两个问题:
- 反复调用同一个线程的 start() 方法是否可行?
- 假如一个线程执行完毕(此时处于TERMINATED状态),再次调用这个线程的 start() 方法是否可行?
- 答:两个问题的答案都是不可行。在 start() 方法内部,有一个 threadStatus 的变量,如果它不等于0,调用 start() 是会直接抛出异常的。在调用一次 start() 之后,threadStatus 的值会改变(threadStatus !=0),此时再次调用 start() 方法会抛出 IllegalThreadStateException 异常,比如,threadStatus 为 2 代表当前线程状态为 TERMINATED。
- 假如今天你下班后准备去食堂吃饭。你来到食堂仅有的一个窗口,发现前面已经有个人在窗口前了,此时你必须得等前面的人从窗口离开才行。假设你是线程t2,你前面的那个人是线程t1。此时t1占有了锁(食堂唯一的窗口),t2正在等待锁的释放,所以此时t2就处于BLOCKED状态。
- 你等了好几分钟现在终于轮到你了,突然你们有一个“不懂事”的经理突然来了。你看到他你就有一种不祥的预感,果然,他是来找你的。他把你拉到一旁叫你待会儿再吃饭,说他下午要去作报告,赶紧来找你了解一下项目的情况。你心里虽然有一万个不愿意但是你还是从食堂窗口走开了。此时,假设你还是线程t2,你的经理是线程t1。虽然你此时都占有锁(窗口)了,“不速之客”来了你还是得释放掉锁。此时你t2的状态就是WAITING。然后经理t1获得锁,进入RUNNABLE状态。要是经理t1不主动唤醒你t2(notify、notifyAll…),可以说你t2只能一直等待了。
- 到了第二天中午,又到了饭点,你还是到了窗口前。突然间想起你的同事叫你等他一起,他说让你等他十分钟他改个bug。好吧,你说那你就等等吧,你就离开了窗口。很快十分钟过去了,你见他还没来,你想都等了这么久了还不来,那你还是先去吃饭好了。这时你还是线程t1,你改bug的同事是线程t2。t2让t1等待了指定时间,此时t1等待期间就属于TIMED_WATING状态。t1等待10分钟后,就自动唤醒,拥有了去争夺锁的资格。
6. JUC
- java.util.concurrent(Java并发包)
- java.util.concurrent.atomic(Java并发原子包)
- java.util.concurrent.locks(Java并发锁包)
package com.java.juc;
/**
* @author rrqstart
* @Description 多线程编程的企业级模板(在高内聚低耦合的前提下,线程操作资源类)
*/
public class SaleTicket {
public static void main(String[] args) {
Ticket ticket = new Ticket();
//Thread类的构造器:Thread(Runnable target, String name)
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i <= 30; i++) {
ticket.saleTicket();
}
}
}, "售票员1").start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i <= 30; i++) {
ticket.saleTicket();
}
}
}, "售票员2").start();
new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i <= 30; i++) {
ticket.saleTicket();
}
}
}, "售票员3").start();
}
}
/**
* 资源类
*/
class Ticket {
private int number = 30;
public synchronized void saleTicket() {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + number-- + "张票,还剩" + number + "张票。");
}
}
}
package com.java.juc;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SaleTicketWithJUC {
public static void main(String[] args) {
Ticket ticket = new Ticket();
//Thread类的构造器:Thread(Runnable target, String name)
new Thread(() -> { for (int i = 0; i <= 30; i++) ticket.saleTicket(); }, "售票员1").start();
new Thread(() -> { for (int i = 0; i <= 30; i++) ticket.saleTicket(); }, "售票员2").start();
new Thread(() -> { for (int i = 0; i <= 30; i++) ticket.saleTicket(); }, "售票员3").start();
}
}
class TicketWithJUC {
private int number = 30;
private final Lock lock = new ReentrantLock();
public void saleTicket() {
lock.lock();
//将获取锁的过程放在try块外是因为如果在获取锁时发生异常,异常抛出的同时不会导致锁无故释放。
try {
if (number > 0) {
System.out.println(Thread.currentThread().getName() + "卖出第" + number-- + "张票,还剩" + number + "张票。");
}
} finally {
lock.unlock();
}
}
}
7. 上下文切换
- 上下文切换(进程切换 / 任务切换)是指 CPU 从一个进程(或线程)切换到另一个进程(或线程)。
- 上下文是指某一时间点 CPU 寄存器和程序计数器的内容。
- 寄存器是cpu内部的少量的速度很快的闪存,通常存储和访问计算过程的中间值提高计算机程序的运行速度。
- 程序计数器是一个专用的寄存器,用于表明指令序列中 CPU 正在执行的位置,存的值为正在执行的指令的位置或者下一个将要被执行的指令的位置,具体实现依赖于特定的系统。
- CPU 通过为每个线程分配 CPU 时间片来实现多线程机制。CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。上下文切换通常是计算密集型的,意味着此操作会消耗大量的 CPU 时间,故线程也不是越多越好。如何减少系统中上下文切换次数,是提升多线程性能的一个重点课题。
8. 并发编程的挑战
减少上下文切换(Content Switch)的方法:
- 无锁并发编程:将数据的 ID 按照 Hash 算法取模分段,不同的线程处理不同段的数据。
- CAS 算法:Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁。
- 使用最少线程:每次从 WAITING 到 RUNNABLE 都会进行一次上下文切换,WAITING 的线程少了系统上下文切换的次数就会减少。
- 使用协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
避免死锁(DeadLock)的方法:
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
- 尝试使用定时锁,使用 lock.tryLock(timeout) 来替代使用内部锁机制。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
资源限制的挑战:
- 硬件资源限制:带宽的上传/下载速度、硬盘读写速度、CPU处理速度。
- 软件资源限制:数据库的连接数、socket 连接数。
解决资源限制问题的方法:
- 对于硬件资源限制,考虑使用服务器集群并行执行程序。
- 对于软件资源限制,考虑使用资源池将资源复用。
9. 线程安全的实现方法
9.1 互斥同步
synchronized关键字:
- 是一种块结构的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。
- 根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
- 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
- 被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。
java.util.concurrent.locks.Lock接口:
- 基于Lock接口,用户能够以非块结构来实现互斥同步。
- 重入锁(ReentrantLock)是Lock接口最常见的一种实现,它与synchronized一样是可重入的。ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁、锁可以绑定多个条件。
- 等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
- 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。
9.2 非阻塞同步
- 互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。
- 基于非阻塞同步(Non-Blocking Synchronization)来编写代码也常被称为无锁(Lock-Free)编程。
- CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。
- J.U.C包为了解决CAS操作的“ABA问题”,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类处于相当鸡肋的位置,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。
9.3 无同步方案
- 要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的,下面介绍其中的两类。
- 可重入代码:这种代码又称纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。所有可重入的代码都是线程安全的,但并非所有的线程安全的代码都是可重入的。可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。我们可以通过一个比较简单的原则来判断代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。
- 线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。我们还是可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。