「Java教程」多线程

多线程的存在,不是提高程序的执行速度。其实是为了提高应用程序的使用率。
程序的执行其实都是在抢 CPU 的资源,CPU 的执行权。
多个进程是在抢这个资源,而其中的某一个进程如果执行路径(线程)比较多,就会有更高的几率抢到 CPU 的执行权。

1. 基本概念

  • 程序:数据结构 + 算法,主要指存放在硬盘上的可执行文件。
  • 进程:主要指运行在内存中的程序;每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含 n 个线程;(进程是系统进行资源分配和调度的一个独立单位)。
  • 线程:线程是进程的一个实体,同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),线程切换开销小;(线程是 cpu 调度和分派的最小单位)。
  • 多进程是指操作系统能同时运行多个任务(程序)。
  • 多线程是指在同一程序(一个进程)中有多个顺序流在执行。
  • 并行与并发:
    • 并行:多个 cpu 实例或者多台机器同时执行一段处理逻辑,是真正的同时。
    • 并发:通过 cpu 调度算法,让用户看上去同时执行,实际上从 cpu 操作层面不是真正的同时。并发往往在场景中有公用的资源,那么针对这个公用的资源往往产生瓶颈,我们会用 TPS 或者 QPS 来反应这个系统的处理能力。
  • 线程和进程一样分为五个阶段:创建就绪状态执行状态等待/挂起/阻塞终止/异常/消亡

2. 实现线程的过程

java.lang.Thread 类主要用于描述线程,Java 虚拟机允许应用程序并发地运行多个执行线程。

  • 自定义类继承 Thread 类并重写 run 方法,然后创建该类的实例调用 start 方法。
  • 自定义类实现 Runnable 接口并重写 run 方法,然后创建该类的对象作为实参去构造 Thread 类型的对象,最后使用 Thread 类对象调用 start 方法。

2.1 实现方式一:继承 Thread 类

  1. 自己描述一个类
  2. 继承父类 Thread
  3. 重写 run 方法
  4. new 一个线程对象,调用 start()方法,让线程进入就绪状态(需要注意的是 start 方法是 Thread 类中的)
1
2
3
4
5
6
7
8
class MyThread extends Thread{
@Override
public void run(){
//这里编写该线程的执行任务
}
}
MyThread mt = new MyThread();
mt.start();

2.2 实现方式二:实现 Runnable 接口

  1. 自己描述一个类
  2. 实现一个父接口 Runnable
  3. 重写 run 方法
  4. new 一个线程对象,new 一个 Thread 并传入线程对象,调用 start()方法,让线程进入就绪状态
1
2
3
4
5
6
7
8
9
class MyThread implements Runnable{
@Override
public void run(){
//这里编写该线程的执行任务
}
}
MyThread mt = new MyThread();
Thread td = new Thread(mt);
td.start();

2.3 两种方式优缺点:

  • 使用继承 Thread 方式代码简单,但 Java 语言只支持单继承,若该类继承 Thread 类后则无法继承其他类
  • 使用实现 Runnable 的方式代码复杂,但不影响该类继承其他类,并且支持多实现,适合多个相同程序代码的线程去处理同一个资源,增加程序健壮性,代码可以被多个线程共享,代码和数据独立。

3. 线程常用方法

3.1 相关方法的解析:

  • Thread():使用无参方式构造对象
  • Thread(String name):根据参数指定的名称来构造对象。
  • Thread(Runnable target):根据参数指定的 Runnable 引用来构造对象。
  • Thread(Runnable target, String name):根据参数指定的 Runnable 引用和名称构造对象。
  • void run():若使用 Runnable 对象作为参数构造的对象来调用该方法,则最终调用 Runnable 对象中的 run 方法,否则该方法啥也不做。
  • void start():用于启动线程,除了主方法线程外新启动一个线程同时执行,Java 虚拟机会自动调用该线程的 run 方法。
  • int getPriority():用于获取线程的优先级,优先级 1-10
  • void setPriority(int):更改线程的优先级

3.2 多线程原理分析

  1. 执行 main 方法的线程叫做主线程,而执行 run 方法的线程叫做子线程。
  2. 对于 start 方法之前的代码来说,由主线程执行一次,当 start 方法调用成功之后,线程的个数由 1 个变成了 2 个,主线程继续向下执行,而新启动的线程去执行 run 方法的代码,两个线程各自独立运行。
  3. 当 run 方法执行完毕后,则子线程结束;当 main 方法执行完毕后,则主线程结束。
  4. 两个线程执行的先后次序没有明确的规定,由系统的调度算法决定。

3.3 线程的编号和名称

  • long getId():用于获取调用对象所表示线程的编号
  • String getName():用于获取调用对象所表示线程的名称
  • void setName():用于设置线程的名称为参数指定的数值
  • static Thread currentThread():获取当前正在执行线程的引用

4. 线程池

  • 为了避免重复的创建线程,线程池的出现可以让线程进行复用。通俗点讲,当有工作来,就会向线程池拿一个线程,当工作完成后,并不是直接关闭线程,而是将这个线程归还给线程池供其他任务使用。
  • 在线程池的编程模式下,任务是提交给整个线程池,而不是直接交给某个线程,线程池在拿到任务后,它就在内部找有无空闲线程,再把任务交给内部某个空闲线程。
  • 一个线程同时只能执行一个任务,但可以同时向一个线程池提交多个任务
    • 接口:Executor,CompletionService,ExecutorService,ScheduledExecutorService
    • 抽象类:AbstractExecutorService
    • 实现类:ExecutorCompletionService,ThreadPoolExecutor,ScheduledThreadPoolExecutor
  • 创建线程的第三种方式是实现 Callable 接口,主要用于线程池

5. 线程的主要状态

  1. 新建状态:使用 new 关键字创建线程后进入状态,此时线程还没有开始执行
  2. 就绪状态:调用 start()进入的状态,此时线程还是没有开始执行
  3. 运行状态:使用线程调度器调用该线程后进入的状态(获得 CPU 执行权),此时线程开始执行,当线程的时间片执行完毕后若没有完成就回到就绪状态,若任务完成进入消亡状态
  4. 消亡状态:当线程的任务执行完成之后进入的状态,此时线程已经终止
  5. 阻塞状态:当线程执行过程中发生了阻塞事件进入的状态,阻塞解除后再回到就绪状态

5.1 线程的休眠

  • 终止线程:通常使用退出标识,使线程正常退出,也就是当 run() 方法完成后线程终止。
  • static void **yield()**:当线程让出处理器(离开 Running 状态),使用当前线程进入 Runnable 状态等待。
  • static void **sleep(times)**:使当前线程从 Running 放弃处理器进入 Block 状态,休眠 times 毫秒,再返回到 Runnable 如果其他线程打断当前线程的 Block(sleep),就会发生 InterruptException。

5.1 线程的等待

  • void **join()**:等待该线程终止,让多个线程同步执行,变成单个线程
  • void **join(long millis)**:表示等待参数指定的毫秒数
  • 对象.wait() 和 **对象.notify()/notifyAll()**可以让线程的状态来回切换
  • sleep()和 wait()的区别:
sleep()和 wait()的区别 sleep() wait()
1.类 Thread 类 Object 类
2.调用 静态 类名. 对象.
3.理解 调用位置的线程等待 对象调用,访问对象的其他线程等待
4.唤醒 不需要唤醒 需要其他对象调用 notify 唤醒
5.锁 不会释放锁 等待后会释放锁

5.2 守护线程

  • boolean **isDeamon()**:用于判断是否为守护线程
  • void **setDeamon(boolean on)**:用于设置线程为守护线程
  • Java 线程有两类:
    • 用户线程:运行在前台,执行具体任务;程序的主线程、连接网络的子线程等都是用户线程
    • 守护线程:运行在后台,为其他前台线程服务
  • 守护线程特点:
    • 一旦所有线程都结束运行,守护线程会随 JVM 一起结束工作
  • 守护线程应用:
    • 数据库连接池中检测的线路,JVM 虚拟机启动后的监测线程;最常见的是垃圾回收线程。
  • 设置守护线程:
    • 可以通过调用 Thread 类的 setDeamon(true)方法来设置当前的线程为守护线程

6. 线程的同步机制

  • 条件争用:当多个线程同时共享访问同一数据时,每个线程都尝试操作该数据,从而导致数据被破坏(corrupted),这种现象称为争用条件。

  • 当多个线程同时访问同一种共享资源时,可能会造成数据的覆盖等不一致性问题,此时就需要对多个线程之间进行通信和协调,该机制就叫做线程的同步机制

  • Java 提供了一种内置的锁机制来支持原子性,使用synchronized关键字来保证线程执行操作的原子性,叫做对象/同步锁机制

  • 特征修饰符 synchronized:表示同步,一个时间点只有一个线程访问

  • 线程安全锁:两种形式是(锁定的永远是对象)

    1. 使用同步代码块的方式,将 synchronized 关键字放在方法体内部
    1
    2
    3
    synchronized(对象){
    //需同步执行(锁定)的代码
    }
    1. 使用同步方法的方式处理,直接使用 synchronized 关键字修饰整个方法,锁定的是调用方法的那个对象
    1
    public synchronized void 方法名(){}
  • 使用 synchronized 保证线程同步时应当注意:

    1. 多个需要同步的线程在访问该同步块时,看到的应该时同一个锁对象引用
    2. 在使用同步块时应当尽量减少同步范围以提高并发的执行效率
  1. 无论 synchronized 关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁――而且同步方法很可能还会被其他线程的对象访问。
  2. 每个对象只有一个锁(lock)与之相关联。
  3. 实现同步是要很大的系统开销作为代价的,甚至可能造成死锁,所以尽量避免无谓的同步控制。

7. 线程的死锁

Java 线程死锁是一个经典的多线程问题,因为不同的线程都在等待那些根本不可能被释放的锁,从而导致所有的工作都无法完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**当两个线程或多个线程之间相互锁定时就形成了死锁**/
//线程一:
public void run() {
synchronized(a) { //表示:持有对象锁a,等待对象锁b
synchronized(b) {
//...
}
}
}
//线程二:
public void run() {
synchronized(b) { //表示:持有对象锁b,等待对象锁a
synchronized(a) {
//...
}
}
}
// 注意:在以后的开发中尽量不要使用同步代码块的嵌套结构。
  • 产生死锁的必要条件:a.互斥条件、b.不可抢占条件、c.占有且申请条件、d.循环等待条件。
  • 隐性死锁:隐性死锁由于不规范的编程方式引起,但不一定每次测试运行时都会出现程序死锁的情形。由于这个原因,一些隐性死锁可能要到应用正式发布之后才会被发现,因此它的危害性比普通死锁更大。
  • 两种导致隐性死锁的情况:加锁次序和占有并等待。
    • 加锁次序:当多个并发的线程分别试图同时占有两个锁时,会出现加锁次序冲突的情形。如果一个线程占有了另一个线程必需的锁,就有可能出现死锁。
    • 占有并等待:如果一个线程获得了一个锁之后还要等待来自另一个线程的通知,可能出现另一种隐性死锁。

7.1 死锁的避免

  • 避免死锁的原则:顺序上锁,反向解锁,不要回头
  • 静态策略:使产生死锁的四个必要条件不能同时具备,从而对进程申请资源的活动加以限制,以保证死锁不会发生。
  • 动态策略:不限制进程有关申请资源的命令,而是对进程所发出的每一个申请资源命令加以动态地检查,并根据检查结果决定是否进行资源分配。具体策略有:安全序列银行家算法

8.内存可见性

8.1 基本概念

  • 可见性:一个线程对共享变量值的修改,能够及时的被其他线程看到
  • 共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量
  • Java 内存模型(JMM):
    • Java Memory Model 描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存中和从内存中读取变量这样的底层细节。
    • 所有的变量都存储在主内存中
    • 每个线程都有自己的独立的工作内存,里面保存该线程使用到的变量的副本(来自主内存的拷贝)
  • Java 内存模型规定:
    • 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
    • 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。
  • 要实现共享变量的可见性,必须保证两点:
    • 线程修改后的共享变量值能够及时从工作内存中刷新到主内存中
    • 其他线程能够及时把共享变量的最新值从主内存更新到自己的工作内存中。
  • Java语言层面支持的可见性实现方式:Synchronized,volatile

8.2 Synchronized 实现可见性

  • Synchronized 能够实现:原子性(同步)、可见性
  • JMM 关于 synchronized 的两条规定:
    • 线程解锁前,必须把共享变量的最新值刷新到主内存中
    • 线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从内存中重新读取最新的值(注意:加锁与解锁需要是同一把锁)
  • 线程执行互斥代码的过程:
    1. 获得互斥锁
    2. 清空工作内存
    3. 从主内存拷贝变量的最新副本到工作内存
    4. 执行代码
    5. 将更改后的共享变量的值刷新到主内存
    6. 释放互斥锁
  • 重排序:代码的书写顺序与实际的执行顺序不同,指令重排序是编译器或处理器为了性能而做的优化
    1. 编译器优化重排序(编译器处理)
    2. 指令级并行重排序(处理器优化)
    3. 内存系统的重排序(处理器读写缓存的优化)
  • as-is-serial:无论如何重排序,程序执行的结果应该与代码的顺序执行结果一致
  • 单线程中重排序不会带来内存可见性问题
  • 多线程中程序交错执行时,重排序可能造成内存可见性问题
不可见的原因 syschronized 解决方案
1.线程的交叉执行 原子性
2.重排序结合线程交叉执行 原子性
3.共享变量未及时更新 可见性

8.3 volatile 实现可见性

  • 深入来说:通过加入内存屏障和禁止重排序优化来实现的。
    • 对 volatile 变量执行写操作时,会在写操作后加入一条 store 屏蔽指令
    • 对 volatile 变量执行读操作时,会在读操作前加入一条 load 屏蔽指令
  • 通俗地讲:volatile 变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样任何时刻,不同的线程总能看到该变量的最新值。
  • 线程volatile 变量的过程:
    1. 改变线程工作内存中 volatile 变量副本的值
    2. 将改变后的副本的值从工作内存刷新到主内存
  • 线程volatile 变量的过程:
    1. 从主内存中读取 volatile 变量的最新值到线程的工作内存中
    2. 从工作内存中读取 volatile 变量的副本
  • volatile 不能保证 volatile 变量复合操作的原子性
  • volatile 适用场景:
    1. 对变量的写操作不依赖其当前值
    2. 该变量没有包含在具有其他变量的不变式中

8.4 Synchronized 和 volatile 比较

  • volatile 不需要加锁,比 synchronized 更轻量级,不会阻塞线程;
  • 从内存可见性角度讲,volatile 读相当于加锁,volatile 写相当于解锁
  • synchronized 既能保证可见性,又能保证原子性,而 volatile 只能保证可见性,无法保证原子性
  • volatile 没有 synchronized 使用广泛。