返回
Featured image of post 初识多线程

初识多线程

初步介绍Java中多线程的使用

目录

相关概念

程序、进程和线程

程序(Program):为实现特定目标或解决特定问题而用‌计算机语言编写的命令序列的集合。是储存在磁盘上的一个二进制文件,是一个 静态 对象。

进程(process):程序的一次执行过程,或是正在内存中运行的 应用程序 。如:运行中的QQ,运行中浏览器。

  • 进程是操作系统调度和分配资源的最小单位(亦是系统运行程序的基本单位),系统会为每个进程分配不同的内存区域。

  • 每个进程都有一个独立的内存空间。系统运行一个程序即是一个进程从创建、运行到消亡的过程。(进程的生命周期)

  • 程序是静态的,进程是动态的。

  • 现代的 操作系统 大都是支持多进程的,即支持同时运行多个程序。比如我们可以一边使用网易云听歌,一边打开 steam 玩游戏。

线程:线程也叫做 轻量级进程,是进程中的一个实体也是进程中的一条执行路径,一个进程中至少有一个线程

  • 线程是CPU调度和执行的基本单位
  • 一个进程若可以在同一时间并行或并发执行多个线程,那么该 进程 就是支持多线程的。

单线程与多线程

  • 一个进程中的多个线程共享相同的内存单元,它们从同一个堆中分配对象,可以访问相同的变量和对象。这就使得 线程间通信 更简便、高效。但多个线程操作共享的系统资源可能会带来安全隐患。

下图红框中的蓝色区域(右侧)为线程独享,黄色区域(左侧)为线程共享。

线程独享区和线程共享区

不同的进程之间是不共享内存的。所以进程之间数据交换和通信的成本很高。

区分 操作系统 调度的基本单位和 CPU 调度的基本单位:

操作系统调度的基本单位是进程:

  • 进程是一个正在执行的程序的实例,它包括程序代码、数据、状态和资源。操作系统管理进程,并在进程之间切换资源。每个进程独立运行,有自己的地址空间。

  • 操作系统的调度,广义上涵盖了资源的管理和分配,而不仅限于 CPU 时间。操作系统将 CPU 时间、内存、文件描述符等资源分配给 各个进程。

CPU 调度的基本单位是线程:

  • 线程是一个进程内的执行单元,它拥有自己的寄存器上下文、栈和程序计数器,但与其他线程共享进程的资源。现代操作系统通常 支持多线程,这允许程序通过多线程实现并行处理。

  • 线程是比进程更细粒度的执行单位,它代表了程序中一个独立的执行路径。一个进程可以包含多个线程,这些线程共享进程的资源, 但有独立的执行序列。因此,CPU 调度通常在线程的级别上进行。

线程调度

  • 分时调度

    所有线程轮流使用 CPU,并且平均分配每个线程占用 CPU 的时间

  • 抢占式调度

让优先级高的线程以 较大的概率 优先使用 CPU。如果线程的优先级相同,那么就会随机选择一个线程进行调度(线程随机性),Java 使用的为抢占式调度。

多线程程序的优点

  • 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
  • 提高计算机系统 CPU 的利用率。
  • 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改。

单核CPU和多核CPU

单核 CPU(单核中央处理器)是指只有一个核心处理器的计算机处理器。‌ 单核 CPU 在工作期间只能执行某一个程序,处理多个程序时只能分时处理。‌

单核 CPU 在一个时间单元内只能执行一个线程的任务。例如,可以把 CPU 的一个核心看成是医院的一位医生,在一定时间内只能给一个病人诊断治疗。所以单核CPU 的调度执行就是,多个线程在经过一系列的前导操作后(前导操作类似于医院挂号,比如有10个窗口可以进行挂号,允许多人一起执行),然后到CPU 处执行时发现就只有一个核心(对应一个医生),大家必须排队执行。

这时候想要提升系统性能只有两个办法,要么提升 CPU 性能(让医生看病快点),要么多加几个 CPU 核心或 CPU(多整几个医生)。多核 CPU 是指有两个及以上核心处理器的计算机处理器。

:多核 CPU 的效率是单核 CPU 效率的倍数吗?比如在其他条件相同的情况下,四核的处理器的执行效率会是单核的 4 倍吗?

:理论上是,但是实际上不可能,因为至少有两方面的约束。

  • 多个核心共用资源的限制

    譬如,4核 CPU 的核心虽然变成了4倍,但是对应的 内存cache寄存器 并没有同步扩充4倍,此时它们就会限制 CPU 的执行效率(木桶原理)。这就好像医院一样,虽然一个诊室从1个医生变成了4个医生,但是做B超检查的机器还是只有一台,这时诊室的性能瓶颈就从医生转到B超检查了。

  • 多核之间的协调管理损耗

    譬如,如果多个核心同时运行两个相关的任务,就需要考虑任务的同步(一个任务的进行依赖于另一个任务的结果),这也需要消耗额外的性能。好比公司的运营,一个人的时候不用开会浪费时间,自己跟自己商量就行了。而两个人就要开会同步工作,协调分配,所以工作效率绝对不可能达到2倍。

并行和并发

并行(parallel)同一时刻 ,有多条指令在一个 CPU 的多个核心 或多个 CPU 上同时执行,此时我们就说这些指令的执行是并行的。比如:多个人同时做不同的事。

并发图1

并发图2

并发(concurrency)一段时间内 ,有多条指令在单个或多个CPU的同一个核心上快速轮换、交替执行,此时我们说这些指令的执行是并发的。并发在宏观上使多个进程具有同时执行的效果。

并发图1

并发图2

并发指的是在一段时间内,宏观上(我们肉眼观察到)有多个程序在同时运行。而单核 CPU 每一时刻只能有一个程序执行,所以微观(实际)上这些程序是分时交替运行的,只不过给人的感觉是在同时运行,因为分时交替运行的时间是非常短的,我们感觉不到线程的切换。

多核 CPU 则可以将这些程序可以分配到多个核心上分别执行,这样多个程序便可以同时执行,从而实现并发。核越多,并行处理的程序越多,能大大的提高电脑运行的效率。

并行与并发

就上图来说:

线程1与线程2在同一时刻由同一个CPU的不同核心执行,它们是并行执行的的。

在一段时间内,线程1、线程3、与线程4是交替在同一个核心上允许的,它们是并发执行的。同理,线程2与线程5也是并发执行的。

通过 Java 使用多线程

JVM 允许程序运行多个线程,所有的线程对象都必须是 java.lang.Thread 类或其子类的实例。

继承 Thread 类

通过继承 Thread 类来 创建启动 多线程的步骤如下:

  1. 定义 Thread 类的子类并重写 run() 方法,每个子类的 run() 方法的方法体就代表了该子类对应线程锁所需要完成的任务。
  2. 创建 Thread 子类的实例,即创建线程对象。
  3. 调用线程对象的 start() 方法来启动线程。
 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
// 自定义线程类
class MyThread extends Thread {
    // 定义指定线程名称的构造方法
    public MyThread(String name) {
        //调用父类的String参数的构造方法,指定线程的名称
        super(name);
    }

    
    // 重写run方法,完成该线程执行的逻辑
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName() + ":正在执行!" + i);
        }
    }
}

public class Test4 {
    public static void main(String[] args) {
        // 创建自定义线程对象1
        MyThread mt1 = new MyThread("子线程1");
        // 开启子线程1
        mt1.start();

        // 创建自定义线程对象2
        MyThread mt2 = new MyThread("子线程2");
        // 开启子线程2
        mt2.start();

        // 在主方法中执行for循环
        for (int i = 0; i < 10; i++) {
            System.out.println("main线程!" + i);
        }
    }
}

通过继承创建并启动线程

也可以通过 匿名子类 来进行线程的创建和启动

1
2
3
4
5
6
7
8
new Thread("新的线程!") {
	@Override
	public void run() {
		for (int i = 0; i < 10; i++) {
			System.out.println(getName() +":正在执行!"+i);
		}
	}
}.start();

实现 Runnable 接口

Java 有单继承的限制,那么当我们无法继承 Thread 类时该如何做呢?核心类库中提供了 Runnable 接口,我们可以实现 Runnable 接口,重写 run() 方法,然后通过 Thread 类的对象代理启动和执行我们重写的 run() 方法

通过实现 Runnable 接口来 创建启动 多线程的步骤如下:

  1. 定义 Runnable 接口的实现类,并重写接口的 run() 方法,该 run() 方法的方法体同样是该线程的线程执行体。
  2. 创建 Runnable 实现类的实例,并以此实例作为 Thread 类的构造器的 target 参数来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
  3. 调用线程对象的 start() 方法,启动线程。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }
}

public class Test6 {
    public static void main(String[] args) {
        // 创建自定义线程任务对象
        MyRunnable mr = new MyRunnable();
        // 创建线程对象
        Thread t = new Thread(mr, "长江");
        t.start();
        for (int i = 0; i < 20; i++) {
            System.out.println("黄河 " + i);
        }
    }
}

通过实现 Runnable 接口,使得该类有了线程类的特征。所有的子线程要执行的代码都必须包含在 run() 方法里面。Runnable 接口实现多线程,最终还是通过 Thread 对象的 API 来进行线程控制的。

同样也可以通过 匿名实现类 来进行线程的创建和启动

1
2
3
4
5
6
7
8
new Thread(new Runnable() {
	@Override
	public void run() {
		for (int i = 0; i < 10; i++) {
			System.out.println(Thread.currentThread().getName() + ":" + i);
		}
	}
}).start();

对比两种方式

联系:

Thread 类本身也实现了 Runnable 接口

1
2
3
public class Thread implements Runnable {
    ...
}

区别:

  • 继承 Thread 类:线程的执行函数为 Thread 子类的 run() 方法
  • 实现 Runnable 接口:线程的执行函数为 Runnable 实现类的 run() 方法

实现 Runnable 接口比继承 Thread 类所具有的优势

  • 避免了单继承的局限性。
  • 多个线程可以共享同一个接口实现类的对象,非常适合多个相同线程来共享同一份资源。
  • 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。

使用多线程的注意事项

  • 每个线程都是通过某个特定 Thread 对象的 run() 来执行分配给线程的任务的,因此把 run() 的方法体称为 线程执行体
  • 通过每个 Thread 对象的 start() 来启动对应线程,而非直接调用 run() 方法。如果直接调用run() 方法,就不会启动一个新线程来执行任务而是由调用 run() 方法的线程来执行
 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
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new TestThread("子线程");
        System.out.printf("当前的线程数为:%d%n", Thread.activeCount());
//        thread.start(); // 2 子线程 3
        thread.run(); // 2 main 2
        thread.join();
    }
}

class TestThread extends Thread {
    public TestThread() {
    }

    public TestThread(String name) {
        super(name);
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
        System.out.printf("当前的线程数为:%d%n", Thread.activeCount());

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 线程可以嵌套创建,即子线程也可以创建子线程。每一个线程只在逻辑上有父子关系,在实际上是平等的
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class Test {
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.printf("%s is %s%n", Thread.currentThread().getName(), "Son Thread");
            new Thread(() -> System.out.printf("%s is %s%n", Thread.currentThread().getName(), "Grandson Thread")).start();
        }).start();

        System.out.println(Thread.currentThread().getName() + " is Main Thread");
    }
}
  • run() 方法由 JVM 来进行调用,什么时候执行、执行的过程控制都由 CPU 的调度决定。
  • 一个线程对象只能调用一次 start() 启动。如果重复调用了,则将抛出 IllegalThreadStateException
  • 如果为重写了 run() 方法的 Thread 子类对象传入 Runnable 的实现类对象,会执行 Thread 子类里的 run() 而不是 Runnable 实现类里的 run()。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class Test {
    public static void main(String[] args) {
        new extendThread(() -> System.out.println("implementThread::run()")).start();
    }
}

class extendThread extends Thread {
    public extendThread() {
    }

    public extendThread(Runnable task) {
        super(task);
    }

    @Override
    public void run() {
        System.out.println("extendThread::run()");
    }
}

上面的代码会输出extendThread::run()。这与 Thread 类 run() 方法的实现有关。

1
2
3
4
5
6
7
8
9
// Thread::run()
@Override
public void run() {
	Runnable task = holder.task;
	if (task != null) {
		Object bindings = scopedValueBindings();
        runWith(bindings, task); // 调用 Runnable 中的 run() 方法
    }
}

如果是通过继承 Thread 并重写 run() 方法的方式来创建线程,那么启动线程时调用的就是我们自己重写的 run() 方法,而不会调用Thread 类本身的 run() 方法。如果是通过传入 Runnable 实现类对象的方式来创建线程,那么启动线程时就会通过 Thread 类自己的 run() 方法来调用 Runnable 中的 run() 方法

守护线程

有一种线程,它是在后台运行的,它的任务是为其他线程提供服务,这种线程被称为 守护线程。JVM 的垃圾回收线程就是典型的守护线程。

守护线程有个显著的特点:如果所有非守护线程都死亡,那么守护线程自动死亡

调用 setDaemon(true) 方法可将指定线程设置为守护线程。守护线程必须在线程启动之前设置,否则会报 IllegalThreadStateException 异常。

调用 isDaemon() 可以判断线程是否是守护线程。

Thread 类常用 API

构造器

API 简介
public Thread() 构造一个新的线程对象
public Thread(String name) 构造一个指定名字的新的线程对象
public Thread(Runnable target) 指定创建线程的执行对象,它实现了 Runnable 接口中的 run() 方法
public Thread(Runnable target,String name) 构造一个带有指定执行对象的新线程对象并指定该线程的名字

方法

API 简介
public void run() 线程的执行函数
public void start() 启动线程,由 JVM 来调用 run() 方法
public String getName() 获取当前线程名称
public void setName(String name) 设置该线程名称,在线程启动后也能设置
public static Thread currentThread() 返回执行该方法的线程对象的引用。在Thread 子类中就是 this,通常用于主线程和 Runnable 实现类
public static void sleep(long millis) 使执行该方法的线程阻塞对应毫秒数,在阻塞状态的线程不会参与 CPU 时间片的分配
public static void yield() 提示当前线程让出其当前的 CPU 时间片,使得其他具有相同或更高优先级的线程可以获得执行机会。并不会强制当前线程放弃时间片,其行为依赖于操作系统的线程调度策略
public static int activeCount() 返回当前正在活跃的线程数
public final boolean isAlive() 测试线程是否处于活动状态。如果线程已经启动且尚未终止,则为活动状态
void join() 使执行该方法的线程一直阻塞等待目标线程终止
void join(long millis) 使执行该方法的线程阻塞等待目标线程终止,最多等待 millis 毫秒,如果没等到就停止等待
void join(long millis, int nanos) 使执行该方法的线程阻塞等待目标线程终止,最多等待 millis 毫秒 nanos 纳秒,如果没等到就停止等待
public final void stop() 已过时 强行结束一个线程的执行,使其直接进入死亡状态。run() 立刻停止可能会导致一些清理性的工作得不到完成,如文件、数据库等的关闭。同时,会立即释放该线程所持有的所有的锁,导致数据得不到同步的处理,出现数据不一致的问题。
public final void suspend() public final void resume() 已过时 暂停、恢复线程的执行。这两个操作就好比播放器的暂停和恢复。二者必须成对出现,否则非常容易发生死锁。suspend() 的调用会导致线程暂停,但不会释放任何锁资源,导致其它线程都无法访问被它占用的锁,直到调用 resume()。
public final int getPriority() 返回目标线程的优先级
public final void setPriority(int newPriority) 为目标线程设置优先级,范围在[1,10]之间。线程启动了也能设置

每个线程都有一定的优先级,每个线程默认的优先级都与创建它的父线程具有相同的优先级,其中主线程的优先级为5。

Thread 类的三个优先级常量:

  • MAX_PRIORITY(10):最高优先级
  • MIN_PRIORITY (1):最低优先级
  • NORM_PRIORITY (5):普通优先级,默认情况下main线程具有普通优先级。

同优先级线程组成先进先出队列(先到先服务),使用分时调度策略。优先级高的线程采用抢占式策略,获得较多的执行机会。

线程的生命周期

JDK1.5 之前:5种状态

线程的生命周期有五种状态,分别为:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)。CPU 需要在多条线程之间进行调度切换,于是线程状态会多次在运行、阻塞、就绪之间切换。

jdk1.5五种线程状态

新建:

当一个 Thread 类或其子类的对象被声明并创建时,新生的线程对象处于新建状态。此时它和其他 Java 对象一样,仅仅由 JVM 为其分配了内存,并初始化了实例变量的值。此时的线程对象并没有任何线程的动态特征,程序也不会执行它的线程体 run()。

就绪:

当线程对象调用了 start() 方法之后,线程就从新建状态转为就绪状态。JVM 会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示已具备了运行的条件,随时可以被调度。至于什么时候被调度,取决于 JVM 里线程调度器的调度。

运行:

如果处于就绪状态的线程获得了 CPU 资源,开始执行 run() 方法内的线程体代码,则该线程处于运行状态。如果计算机只有一个 CPU 核心,那么在任何时刻只会有一个线程处于运行状态;如果计算机有多个核心,将会有多个线程并行(Parallel)执行。对于 抢占式策略 的系统而言,系统会给每个可执行的线程一个 时间片 来处理任务,当该时间用完系统就会剥夺该线程所占用的资源,让其回到 就绪状态 等待下一次被调度。此时其他线程将获得执行机会,而在选择下一个线程时系统会适当考虑线程的优先级

阻塞:

当在 运行 过程中的线程遇到如下情况时,会让出 CPU 时间片并保存上下文中止自己的执行,进入 阻塞 状态:

  • 线程调用了 sleep() 方法,主动放弃所占用的 CPU 资源;
  • 线程试图获取一个同步监视器或锁,但该同步监视器或锁正被其他线程持有;
  • 线程执行过程中,线程调用了 wait(),让它等待通知 notify();
  • 线程执行过程中,线程调用了 wait(time),让它在一定时间内等待通知 notify();
  • 线程执行过程中,需要 join() 等待其他线程死亡。
  • 线程被调用 suspend() 方法被挂起(已过时,因为容易发生死锁);

当前正在执行的线程被阻塞后,其他线程就有机会执行了。针对如上情况,当发生如下情况时会解除阻塞,让该线程重新进入 就绪 状态,等待线程调度器再次调度它

  • 线程的 sleep() 时间到;
  • 线程成功获得了同步监视器或锁;
  • 线程等到了通知 notify();
  • 线程 wait() 的时间到了
  • 需要 join() 等待的线程结束了;
  • 被挂起的线程被调用了resume() 恢复(已过时,因为容易发生死锁);

死亡:

线程会以以下三种方式之一结束,结束后的线程就处于死亡状态:

  • run() 方法执行完成,线程正常结束;
  • 线程执行过程中抛出了一个未捕获的异常(Exception)或错误(Error);
  • 直接调用该线程的 stop() 来结束该线程(已过时)

JDK1.5及之后:6种状态

java.lang.Thread.State 枚举类中这样定义:

1
2
3
4
5
6
7
8
public enum State {
	NEW,
	RUNNABLE,
	BLOCKED,
	WAITING,
	TIMED_WAITING,
	TERMINATED;
}

NEW(新建):线程刚被创建,但是并未启动。即还没调用start方法。

RUNNABLE(可运行):JDK1.5 之后就绪状态和运行状态统一为了可运行状态。因为对于 Java 本身来说最多只能将线程标记为可运行,因为线程什么时候调度运行并不是 JVM 来控制的,而是 OS 来进行决定的,而且调度的时间非常短暂,因此对于 Java 对象的状态来说,无法区分。

Teminated(终止):表明此线程已经结束生命周期,终止运行。

根据 Thread.State 中的定义,阻塞状态被分为三种,分别是:BLOCKEDWAITINGTIMED_WAITING

  • BLOCKED(锁阻塞):在 API 中的介绍为:一个正在阻塞、等待一个监视器锁(锁对象)的线程会处于这一状态。只有获得锁对象的线程才能有执行机会。比如,线程 A 与线程 B 的线程代码中使用同一把锁,如果线程 A 获取到锁,线程 A 进入到 Runnable 状态,而线程 B 就进入到 Blocked 锁阻塞状态。

  • TIMED_WAITING(计时等待):在 API 中的介绍为:一个正在限时等待另一个线程执行一个(唤醒)动作的线程处于这一状态。如当前线程执行过程中遇到 Thread 类中的静态方法 sleepjoin,Object 类中的静态方法 wait 或 LockSupport 类中的 park 方法,并且在调用这些方法时,设置了时间,那么当前线程会进入TIMED_WAITING 状态,直到计时结束,或被提前唤醒中断。

  • WAITING(无限等待):在API中介绍为:一个正在无限期等待另一个线程执行一个特别的(唤醒)动作的线程处于这一状态。如,当前线程执行过程中遇到遇到 Object 类的 wait,Thread 类的 join,LockSupport 类的 park 方法,并且在调用这些方法时没有指定时间,那么当前线程会进入 WAITING 状态,直到被唤醒。

    • 通过 Object 类的 wait 方法进入 WAITING 状态的线程需要被 Object 的 notify / notifyAll 方法唤醒;
    • 通过 Condition 的 await 方法进入 WAITING 状态的线程需要被 Condition 的 signal 方法唤醒;
    • 通过 LockSupport 类的 park 方法进入 WAITING 状态的线程需要被 LockSupport 类的 unpark 方法唤醒;
    • 通过 Thread 类的 join 方法进入 WAITING 状态的线程,只有被调用 join 方法的线程对象的生命周期结束才能让当前线程恢复。

当线程从 WAITING 或 TIMED_WAITING 状态恢复到 Runnable 状态时,如果发现当前线程需要但是又没有得到锁时,那么会立刻转入 BLOCKED 状态。

jdk1.5及之后的6种状态图1

jdk1.5及之后的6种状态图2

我们在翻阅 API 的时候会发现 Timed Waiting(计时等待)与 Waiting(无限等待) 状态联系是很紧密的。

比如进入 waiting(无限等待)状态的 wait 方法是空参的,而进入 timed waiting(计时等待)的 wait 方法是带参的。这种带参的方法是用倒计时来实现计时的操作,相当于我们生活中的小闹钟,如果我们设定好时间,那么它就会到时通知。如果提前得到(唤醒)通知,那么设定的时间就没有存在的必要了(如果你比闹钟叫你还早醒,那么你就可以提前把闹钟关掉了,因为闹钟没有必要再继续倒计时叫你起床了)。这种设计方案其实是一举两得,如果没有得到(唤醒)通知,那么线程就处于 Timed Waiting 状态,直到倒计时完毕自动醒来;如果在倒计时期间得到(唤醒)通知,那么线程从 Timed Waiting 状态立刻唤醒,这可以有效的避免某个线程因为没有得到通知而一直处于 Waiting 状态。

 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
public class ThreadStateTest {
    public static void main(String[] args) throws InterruptedException {
        SubThread t = new SubThread();
        System.out.println(t.getName() + " 状态 " + t.getState());
        t.start();

        while (Thread.State.TERMINATED != t.getState()) {
            System.out.println(t.getName() + " 状态 " + t.getState());
            Thread.sleep(500);
        }
        System.out.println(t.getName() + " 状态 " + t.getState());
    }
}

class SubThread extends Thread {
    @Override
    public void run() {
        while (true) {
            for (int i = 0; i < 10; i++) {
                System.out.println("打印:" + i);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            break;
        }
    }
}

使用 jps 命令和 jstack 命令来获取线程的状态:

通过jps和jstack来获取线程状态

jps 获取线程 id 需要在线程正在运行的时候才能进行,如果使用 jstack 发出了权限不足的警告,请用管理员模式打开 cmd 或 powershell,具体方式见度娘。

再推荐一些其他的原生 Java 命令Java常用命令:jps、jstack、jmap、jstat(带有实例教程)

线程安全问题及解决

当我们使用多个线程访问同一份资源(可以是同一个变量、同一个文件、同一条记录等)的时候,若多个线程只有读操作,那么就不会发生线程安全问题。但是如果多个线程中既有线程对资源进行读操作,也有线程进行写操作那就 可能会 出现线程安全问题。

什么样的资源能被或不能被共享

  • 线程方法中的局部变量不能共享
1
2
3
4
5
6
7
8
9
class Window extends Thread {
    public void run() {
        int ticket = 100; // 不能共享
        while (ticket > 0) {
            System.out.println(getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

我们可以通过 局部内部类 来共享父线程的局部变量

 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
public class Test {
    public static void main(String[] args) {
        final int[] i = {10}; // 对于后面定义的 Runnable 类是可见的
        Runnable handler = new Runnable() {
            @Override
            public void run() {
                while (i[0] > 0) {
                    System.out.println(Thread.currentThread().getName() + " i: " + --i[0]);
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        };
        final int[] j  = {100}; // 对于前面定义的 Runnable 类是不可见的

        Thread t1 = new Thread(handler);
        Thread t2 = new Thread(handler);
        t1.start();
        t2.start();
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

局部内部类要想访问外部资源,外部资源必须是 final 的,如果只是读取外部资源就可以不写final,但如果要进行修改就必须用 final 修饰。有关内部类的知识可以参考:Java 内部类

这种方式需要使用局部内部类或函数式编程,要实现这种方式并不方便,也没什么优点,所以就不推荐这么做了。

  • 不同对象的实例变量不共享
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class TicketWindow extends Thread {
    private int ticket = 100; // 如果使用同一个对象,就是共享的;如果使用不同对象,就不是共享的。因为一个 Thread 或其子类 对象不能 start 两次,但 Runnable 实现类能够作为参数传递多次,所以主要用于Runnable中,详见下面 `同一个对象的实例变量共享`

    public void run() {
        while (ticket > 0) {
            System.out.println(getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SaleTicketDemo {
    public static void main(String[] args) {
        // 下面的线程不共享 ticket
        TicketWindow w1 = new TicketWindow();
        TicketWindow w2 = new TicketWindow();
        TicketWindow w3 = new TicketWindow();
    }
}
  • 同一个类的静态变量是共享的
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class TicketWindow extends Thread {
    private static int ticket = 100; // 同一个类的不同对象可以共享,主要在通过继承 Thread 创建线程时使用

    public void run() {
        while (ticket > 0) {
            System.out.println(getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SaleTicketDemo {
    public static void main(String[] args) {
        // 下面的线程共享 ticket
        TicketWindow w1 = new TicketWindow();
        TicketWindow w2 = new TicketWindow();
        TicketWindow w3 = new TicketWindow();
    }
}
  • 同一个对象的实例变量共享
 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
class TicketSaleRunnable implements Runnable {
    private int ticket = 100;

    public void run() {
        while (ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SaleTicketDemo {
    public static void main(String[] args) {
        TicketSaleRunnable tr = new TicketSaleRunnable();
        
		// 下面的线程共享ticket 因为它们使用了同一个TicketSaleRunnable 对象
        // 同理,如果它们使用了不同的 TicketSaleRunnable 对象就是不共享的
        Thread t1 = new Thread(tr, "窗口一");
        Thread t2 = new Thread(tr, "窗口二");
        Thread t3 = new Thread(tr, "窗口三");

        t1.start();
        t2.start();
        t3.start();
    }
}
  • 共享同一个资源对象(同一个对象的实例变量共享
 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
48
49
50
51
52
53
54
// 1、编写资源类
class Ticket {
    private int ticket = 100;

    public void sale() {
        if (ticket > 0) {
            try {
                Thread.sleep(10);//加入这个,使得问题暴露的更明显
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        } else {
            throw new RuntimeException("没有票了");
        }
    }

    public int getTicket() {
        return ticket;
    }
}

public class SaleTicketDemo {
    public static void main(String[] args) {
        // 2、创建资源对象
        Ticket ticket = new Ticket();

        // 3、启动多个线程操作资源类的对象
        Thread t1 = new Thread("窗口一") {
            public void run() {
                while (true) {
                    ticket.sale();
                }
            }
        };
        Thread t2 = new Thread("窗口二") {
            public void run() {
                while (true) {
                    ticket.sale();
                }
            }
        };
        Thread t3 = new Thread(new Runnable() {
            public void run() {
                ticket.sale();
            }
        }, "窗口三");
        
        t1.start();
        t2.start();
        t3.start();
    }
}

使用同步机制来解决线程安全问题

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Test {
    public static void main(String[] args) {
        Runnable handler = new Runnable() {
            private int ticket = 100;
            @Override
            public void run() {
                while (ticket > 0) {
                    // 使用 sleep 使问题更容易出现
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                    System.out.println(Thread.currentThread().getName() + ":" + ticket--);
                }
            }
        };

        new Thread(handler).start();
        new Thread(handler).start();
    }
}

在上述代码中,可能会出现一张票被卖多次或负数票等情况

重票现象

要解决上述多线程并发访问同一个资源导致的安全性问题,Java 中提供了同步机制(synchronized)来解决。使用同步机制,在某个线程修改共享资源的时候,其他线程不能访问该资源,只有等待修改完毕同步之后,才能去抢夺CPU 和锁资源完成对应的操作。同步操作保证了数据的同步性,解决了线程不安全的现象。

同步机制

注意:在任何时候最多允许一个线程拥有同步锁,谁拿到锁谁就进入代码块,其他的线程只能在外等着(处于 BLOCKED 状态)

同步机制解决线程安全问题的原理

同步机制的原理,其实就相当于给某段代码加“锁”,任何线程想要执行这段代码,都要先获得“锁”,我们称它为同步锁。

Java 对象在堆中的数据分为分为对象头、实例变量、空白的填充(用于内存对齐)。而对象头中包含:

  • Mark Word:记录了和当前对象有关的GC、锁标记等信息。
  • 指向类的指针:每一个对象需要记录它是由哪个类创建出来的。
  • 数组长度(只有数组对象才有)

哪个线程获得了“同步锁”对象之后,”同步锁“对象就会记录这个线程的ID,这样其他线程就只能等待了,除非这个线程”释放“了锁对象,其他线程才能重新获得 / 占用”同步锁“对象。

在《Thinking in Java》中是这么说的:对于并发工作,你需要某种方式来防止两个任务访问相同的资源(其实就是共享资源竞争)。 防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前,就无法访问它了,而在其被解锁之时,另一个任务就可以锁定并使用它了。

同步代码块和同步方法

同步代码块synchronized 关键字可以用于某个区块前面,表示只对这个区块的资源实行互斥访问。

1
2
3
4
// 示例
synchronized (同步锁对象) {
     // 需要同步操作的代码
}

同步方法synchronized 关键字直接修饰方法,表示同一时刻只有一个线程能进入这个方法,其他线程在外面等着。

1
2
3
4
// 示例 权限修饰符、返回值、函数名、参数列表都可以修改
private synchronized void method(){
    // 可能会产生线程安全问题的代码
}

同步锁对象:

同步锁对象可以是任意类型,但是必须保证竞争“同一个共享资源”的多个线程必须使用同一个“同步锁对象”

对于同步代码块来说,同步锁对象是由程序员手动指定的,但很多时候也是指定为 this类名.class(类对象,一个类只会有一个类对象)

但是对于同步方法来说,同步锁对象只能是默认的:

  • 静态方法:当前类的 Class 对象(类名.class)

  • 非静态方法:this

使用同步机制时的思考顺序

  1. 如何找问题,即代码是否存在线程安全问题
    • 明确哪些代码是多线程执行的代码
    • 明确多个线程是否有共享数据
    • 明确多线程运行的代码中是否有语句操作共享数据
  2. 如何解决
    • 对多条操作共享数据的语句只能同时让一个线程执行,在执行过程中其他线程不可以参与执行。即所有操作共享数据的语句都要放在同步器范围中
  3. 注意事项
    • 同步范围太小:不能解决安全问题
    • 同步范围太大:因为一旦某个线程抢到锁其他线程就只能等待(变成单线程),所以如果范围太大就会让原本可以多线程执行的地方也变成单线程执行,这样程序的执行效率就会降低,不能合理利用CPU资源。

代码演示

静态方法加锁

 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
class TicketSaleThread extends Thread {
    private static int ticket = 100;

    public void run() { //直接锁这里,肯定不行,会导致,只有一个窗口卖票
        while (ticket > 0) {
            saleOneTicket();
        }
    }

    public synchronized static void saleOneTicket() { //锁对象是TicketSaleThread类的Class对象,而一个类的Class对象在内存中肯定只有一个
        if (ticket > 0) { //不加条件,相当于条件判断没有进入锁管控,线程安全问题就没有解决
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class Test {
    public static void main(String[] args) {
        TicketSaleThread t1 = new TicketSaleThread();
        TicketSaleThread t2 = new TicketSaleThread();
        TicketSaleThread t3 = new TicketSaleThread();

        t1.setName("窗口1");
        t2.setName("窗口2");
        t3.setName("窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}

非静态方法加锁

 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
public class Test {
    public static void main(String[] args) {
        TicketSaleRunnable tr = new TicketSaleRunnable();
        Thread t1 = new Thread(tr, "窗口一");
        Thread t2 = new Thread(tr, "窗口二");
        Thread t3 = new Thread(tr, "窗口三");

        t1.start();
        t2.start();
        t3.start();
    }
}

class TicketSaleRunnable implements Runnable {
    private int ticket = 100;

    public void run() {//直接锁这里,肯定不行,会导致,只有一个窗口卖票
        while (ticket > 0) {
            saleOneTicket();
        }
    }

    public synchronized void saleOneTicket() { //锁对象是this,这里就是TicketSaleRunnable对象,因为上面3个线程使用同一个TicketSaleRunnable对象,所以可以
        if (ticket > 0) { //不加条件,相当于条件判断没有进入锁管控,线程安全问题就没有解决
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

同步代码块

 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
48
49
50
51
52
53
54
55
56
57
58
59
60
public class Test9 {
    public static void main(String[] args) {
        // 2、创建资源对象
        Ticket ticket = new Ticket();

        // 3、启动多个线程操作资源类的对象
        Thread t1 = new Thread("窗口一") {
            public void run() { // 不能给 run() 直接加锁,因为t1,t2,t3的三个 run 方法分别属于三个 Thread 类对象,
                // run 方法是非静态方法,那么锁对象默认选 this,那么锁对象根本不是同一个 所以需要我们自己指定锁对象
                while (true) {
                    synchronized (ticket) {
                        ticket.sale();
                    }
                }
            }
        };

        Thread t2 = new Thread("窗口二") {
            public void run() {
                while (true) {
                    synchronized (ticket) {
                        ticket.sale();
                    }
                }
            }
        };
        Thread t3 = new Thread(new Runnable() {
            public void run() {
                while (true) {
                    synchronized (ticket) {
                        ticket.sale();
                    }
                }
            }
        }, "窗口三");


        t1.start();
        t2.start();
        t3.start();
    }
}

// 1、编写资源类
class Ticket {
    private int ticket = 1000;

    public void sale() {//也可以直接给这个方法加锁,锁对象是this,这里就是Ticket对象
        if (ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        } else {
            throw new RuntimeException("没有票了");
        }
    }

    public int getTicket() {
        return ticket;
    }
}

是否释放锁的操作

任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?

释放锁的操作

  • 当前线程的同步方法、同步代码块执行结束。

  • 当前线程在同步代码块、同步方法中遇到 break、return 终止了该代码块、该方法的继续执行。

  • 当前线程在同步代码块、同步方法中出现了未处理的 Error 或 Exception,导致当前线程异常结束。

  • 当前线程在同步代码块、同步方法中执行了锁对象的 wait() 方法,当前线程被挂起,并释放锁。

不会释放锁的操作

  • 线程执行同步代码块或同步方法时,程序调用 Thread.sleep()、Thread.yield() 方法暂停当前线程的执行。

  • 线程执行同步代码块时,其他线程调用了该线程的 suspend() 方法将该该线程挂起,该线程不会释放锁(同步监视器)。

    • 应尽量避免使用 suspend() 和 resume() 这样的过时的方法来控制线程。

再谈同步

单例模式的线程安全问题

饿汉式没有线程安全问题

饿汉式:在类初始化时就直接创建单例对象,而类初始化过程是没有线程安全问题的,

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 形式1
public class HungrySingle {
    private static final HungrySingle INSTANCE = new HungrySingle(); // 对象是否声明为 final 都可以
    
    private HungrySingle() {}
    
    public static HungrySingle getInstance(){
        return INSTANCE;
    }
}

// 形式2 通过枚举类实现
public enum HungryOne {
    INSTANCE
}

懒汉式有线程安全问题

 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 形式1
public class LazyOne {
    private static LazyOne instance;

    private LazyOne() {}

    // 方式1:使用同步方法
    public static synchronized LazyOne getInstance1() {
        if (instance == null) {
            instance = new LazyOne();
        }
        return instance;
    }

    // 方式2:使用同步代码块
    public static LazyOne getInstance2() {
        synchronized (LazyOne.class) {
            if (instance == null) {
                instance = new LazyOne();
            }
            return instance;
        }
    }

    // 方式3:使用双重判空和同步代码块 只有创造实例才会获取同步锁,其余时候都不会获取锁,能够提升效率
    // 一定要双重判空
    public static LazyOne getInstance3() {
        if (instance == null) {
            synchronized (LazyOne.class) {
                try {
                    Thread.sleep(10); // 加这个代码,暴露问题
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                if (instance == null) {
                    instance = new LazyOne();
                }
            }
        }

        return instance;
    }
    
    /*
        注意:
        上述方式3中有指令重排问题
        ① mem = allocate(); 为单例对象分配内存空间
        ② instance = mem; 将 instance 指向分配的空间,但是空间还没有初始化
        ③ ctorSingleton(instance); 为单例对象调用构造器

        如果在创建的②③过程中又来了一个线程,此时 instance 已经不是 null 了,但是对象并没有初始化完成。
        如果另一个线程返回了这个还未初始化的对象,并且使用了它就会有问题

        从 JDK2 开始,分配空间、引用内存空间、调用构造器初始化会在线程的工作存储区一次性完成,然后复制到主存储区。但是需要 volatile 关键字来避免指令重排。
    */
}

// 形式2 使用内部类
public class LazySingle {
    private LazySingle() {}
    
    public static LazySingle getInstance(){
        return Inner.INSTANCE;
    }
    
    private static class Inner{
        static final LazySingle INSTANCE = new LazySingle();
    }
}

内部类只有在外部类被调用才会加载(懒汉式),然后我们通过直接赋值的方式产生 INSTANCE 实例,没有线程安全问题(饿汉式)。

此模式具有之前两个模式的优点,同时屏蔽了它们的缺点,是最好的单例模式。

此时的内部类,使用enum进行定义也是可以的。

死锁

不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。

死锁

一旦出现死锁,整个程序既不会发生异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

小故事:

面试官:你能解释清楚什么是死锁,我就录取你!
面试者:你录取我,我就告诉你什么是死锁!
……
恭喜你,面试通过了。

 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class A {
    public synchronized void foo(B b) {
        System.out.println("当前线程名: " + Thread.currentThread().getName()
                + " 进入了A实例的foo方法"); // ①
        try {
            Thread.sleep(200);
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
        System.out.println("当前线程名: " + Thread.currentThread().getName()
                + " 企图调用B实例的last方法"); // ③
        b.last();
    }

    public synchronized void last() {
        System.out.println("进入了A类的last方法内部");
    }
}

class B {
    public synchronized void bar(A a) {
        System.out.println("当前线程名: " + Thread.currentThread().getName()
                + " 进入了B实例的bar方法"); // ②
        try {
            Thread.sleep(200);
        } catch (InterruptedException ex) {
            ex.printStackTrace();
        }
        System.out.println("当前线程名: " + Thread.currentThread().getName()
                + " 企图调用A实例的last方法"); // ④
        a.last();
    }

    public synchronized void last() {
        System.out.println("进入了B类的last方法内部");
    }
}

public class DeadLock implements Runnable {
    A a = new A();
    B b = new B();

    public void init() {
        Thread.currentThread().setName("主线程");
        // 调用a对象的foo方法
        a.foo(b);
        System.out.println("进入了主线程之后");
    }

    public void run() {
        Thread.currentThread().setName("副线程");
        // 调用b对象的bar方法
        b.bar(a);
        System.out.println("进入了副线程之后");
    }

    public static void main(String[] args) {
        DeadLock dl = new DeadLock();
        new Thread(dl).start();
        dl.init();
    }
}

诱发死锁的原因:

  • 互斥条件
  • 占用且等待
  • 不可抢夺(或不可抢占)
  • 循环等待

以上4个条件同时出现时就会触发死锁。

解决死锁:

诱发死锁需要4个条件同时成立,所以我们只要任意打破其中一个条件就可以了:

  • 针对条件1:互斥条件基本上无法被破坏。因为线程需要通过互斥解决安全问题。
  • 针对条件2:可以考虑一次性申请所有所需的资源,这样就不存在等待的问题。
  • 针对条件3:占用部分资源的线程在进一步申请其他资源时,如果申请不到,就主动释放掉已经占用的资源。
  • 针对条件4:可以将资源改为线性顺序。申请资源时,先申请序号较小的,这样避免循环等待问题。

Lock 锁

锁是 JDK5.0 的新增功能,用于保证线程的安全。与采用 synchronized 相比,Lock 可提供多种锁方案,更灵活、更强大。Lock 通过显式定义同步锁对象来实现同步。同步锁使用 Lock 对象充当。

java.util.concurrent.locks.Lock 接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象

在实现线程安全的控制中,比较常用的是 ReentrantLock,可以显式加锁、释放锁。ReentrantLock 类实现了 Lock 接口,它拥有与 synchronized 相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。

Lock锁也称同步锁,获取锁与释放锁方法,如下:

  • public void lock():获取锁
  • public void unlock() :释放锁。
 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
// 如果同步代码有异常,要将 unlock() 写入 finally 语句块。
import java.util.concurrent.locks.ReentrantLock;

class Window implements Runnable {
    int ticket = 100;
    // 1. 创建Lock的实例,必须确保多个线程共享同一个Lock实例
    private final ReentrantLock lock = new ReentrantLock();

    public void run() {

        while (true) {
            try {
                // 2. 调动lock(),实现需共享的代码的锁定
                lock.lock();
                if (ticket > 0) {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(ticket--);
                } else {
                    break;
                }
            } finally {
                // 3. 调用unlock(),释放共享代码的锁定
                lock.unlock();
            }
        }
    }
}

public class ThreadLock {
    public static void main(String[] args) {
        Window t = new Window();
        Thread t1 = new Thread(t);
        Thread t2 = new Thread(t);

        t1.start();
        t2.start();
    }
}
  1. Lock 是显式锁(手动开启和关闭锁,别忘记关闭锁),synchronized 是隐式锁,出了作用域、遇到异常等自动解锁
  2. Lock 只有代码块锁,synchronized 有代码块锁和方法锁
  3. 使用 Lock 锁,JVM 将花费较少的时间来调度线程,性能更好。并且 Lock 具有更好的扩展性(提供更多的子类),更体现面向对象的特性。
  4. Lock 锁可以对读不加锁,对写加锁,synchronized 不可以
  5. Lock 锁可以有多种获取锁的方式,比如可以从 sleep 的线程中抢到锁,而 synchronized 不可以

开发建议中处理线程安全问题优先使用顺序为:

Lock > 同步代码块 > 同步方法

进程间通信

当我们需要多个线程来共同完成一件任务,并且希望它们按照一定顺序来执行,那么多线程之间就需要一些通信机制来协调它们的工作,以此实现多线程共同操作一份数据。

比如:线程 A 用来生产包子的,线程 B 用来吃包子的,包子可以理解为同一资源。线程 A 与线程 B 处理的动作,一个是生产,一个是消费,此时 B 线程必须等到 A 线程完成后才能执行,那么线程 A 与线程 B 之间就需要线程通信,即等待唤醒机制。这种有线程生产资源,也有线程消费资源的模式也叫做生产消费者模型

等待唤醒机制

等待唤醒机制是多个线程间的一种协作机制。谈到线程我们经常想到的是线程间的竞争(race),比如去争夺锁,但这并不是故事的全部,线程间也会有协作机制。

在一个线程满足某个条件时(wait() / wait(time)),就会进入等待状态, 等待其他线程执行完它们的指定代码过后来将其唤醒(notify())或可以指定 wait 的时间,等时间到了自动唤醒;在有多个线程进行等待时,如果需要,可以使用 notifyAll() 来唤醒所有的等待线程。wait / notify 就是线程间的一种协作机制。

  • wait:线程不再活动,也不再参与调度,进入 wait set 中,因此不会浪费 CPU 资源,也不会去竞争锁了。这时的线程状态是 WAITING 或 TIMED_WAITING。它要等着别的线程执行一个特别的动作,即“通知(notify)”或者等待时间到。当线程被唤醒后,在对象上等待的线程会从wait set 中释放出来,重新进入到调度队列(ready queue)中

  • notify:选取所通知对象的 wait set 中的一个线程释放

  • notifyAll:释放所通知对象的 wait set 上的全部线程

注意:

被通知的线程在唤醒后也不一定能立即恢复执行,因为它当初中断的地方是在同步块内,而此刻它已经不持有锁,所以它需要再次尝试去获取锁(很可能面临其它线程的竞争),成功后才能在当初调用 wait 方法之后的地方恢复执行

总结:

  • 如果能获取锁,线程就从 WAITING 状态变成 RUNNABLE(可运行) 状态;
  • 否则,线程就从 WAITING 状态又变成 BLOCKED(等待锁) 状态

调用 wait 和 notify 需注意的细节

  1. wait 方法与 notify 方法必须要在同步代码块或者是同步函数中使用。因为必须要通过锁对象调用这2个方法。否则会报 java.lang.IllegalMonitorStateException 异常
  2. wait 方法与 notify 方法必须要由同一个锁对象调用。因为对应的锁对象只能通过 notify 唤醒在自己 wait set 中 wait 的线程。
  3. wait 方法与 notify 方法属于 Object 类。因此锁对象可以是任意对象,因为 Java 中所有的类都是 Object 类的子类。

生产者消费者问题

等待唤醒机制可以解决经典的生产者消费者问题。生产者与消费者问题(producer-consumer problem),也称有限缓冲问题(bounded-buffer problem),是一个多线程同步问题的经典案例。该问题描述了两个(多个)共享固定大小缓冲区的线程即所谓的“生产者”和“消费者”在实际运行时会发生的问题。

生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。

举例:

生产者(Productor)将产品交给店员(Clerk),而消费者(Customer)从店员处取走产品。店员一次只能持有固定数量的产品,如果生产者试图生产更多的产品,店员会叫生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。

生产者消费者模型中的关键问题:

  • 线程安全问题:因为生产者与消费者共享数据缓冲区,这可能会产生线程安全问题,不过这个问题可以使用同步解决。
  • 线程的协调工作问题:要解决该问题,就必须让生产者线程在缓冲区满时等待状态(wait)并进入阻塞状态,等到消费者消耗了缓冲区中的数据的时候,再通知(notify)正在等待的线程恢复到就绪状态,重新开始往缓冲区添加数据。同样,也可以让消费者线程在缓冲区空时进入等待状态(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
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
public class ProducerAndConsumer {
    public static void main(String[] args) {
        Clerk clerk = new Clerk();
        Producer p1 = new Producer(clerk);

        Consumer c1 = new Consumer(clerk);
        Consumer c2 = new Consumer(clerk);

        p1.setName("生产者1");
        c1.setName("消费者1");
        c2.setName("消费者2");

        p1.start();
        c1.start();
        c2.start();
    }
}

// 生产者
class Producer extends Thread {
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {

        System.out.println("=========生产者开始生产产品========");
        while (true) {

            try {
                Thread.sleep(40);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 要求clerk去增加产品
            clerk.addProduct();
        }
    }
}

// 消费者
class Consumer extends Thread {
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        System.out.println("=========消费者开始消费产品========");
        while (true) {

            try {
                Thread.sleep(90);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            //要求clerk去减少产品
            clerk.minusProduct();
        }
    }
}

// 资源类
class Clerk {
    private int productNum = 0;// 产品数量
    private static final int MAX_PRODUCT = 20;
    private static final int MIN_PRODUCT = 0;

    // 增加产品
    public synchronized void addProduct() {
        if (productNum < MAX_PRODUCT) {
            productNum++;
            System.out.println(Thread.currentThread().getName() +
                    "生产了第" + productNum + "个产品");
            //唤醒消费者
            this.notifyAll();
        } else {

            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    // 减少产品
    public synchronized void minusProduct() {
        if (productNum > MIN_PRODUCT) {
            System.out.println(Thread.currentThread().getName() +
                    "消费了第" + productNum + "个产品");
            productNum--;

            // 唤醒生产者
            this.notifyAll();
        } else {

            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

区分 sleep 和 wait

相同点:

  • 一旦执行,都会使得当前线程结束执行状态,进入阻塞状态

不同点:

  • 定义方法所属的类
    • sleep() 在 Thread 中定义。
    • wait() 在 Object 中定义
  • 使用范围
    • sleep() 可以在任何需要使用的位置被调用
    • wait() 必须使用在同步代码块或同步方法中
  • 在同步结构中使用,是否会释放同步锁
    • sleep() 不会释放同步监视器
    • wait() 会释放同步监视器
  • 结束等待的方式
    • sleep() 指定时间一到就结束阻塞
    • wait() 可以指定时间也可以无限等待直到 notify 或 notifyAll 。

JDK5.0 新增创建线程的方式

实现 Callable 接口

与使用 Runnable 相比, Callable 功能更强大

  • 相比 run() 方法,Callable 中的 call() 方法可以有返回值(需要借助FutureTask类,获取返回结果)
  • 方法可以抛出异常

Future 接口

  • 可以对 Runnable、Callable 任务的执行进行取消、查询是否完成、获取结果等操作
  • FutureTask 同时实现了 Runnable 和 Future接口。它既可以作为Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值
 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
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

// 1.创建一个实现Callable的实现类
class NumThread implements Callable {
    // 2.实现call方法,将此线程需要执行的操作声明在call()中
    @Override
    public Object call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            if (i % 2 == 0) {
                System.out.println(i);
                sum += i;
            }
        }
        return sum;
    }
}


public class CallableTest {
    public static void main(String[] args) {
        // 3.创建Callable接口实现类的对象
        NumThread numThread = new NumThread();

        // 4.将此Callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask的对象
        FutureTask futureTask = new FutureTask(numThread);
        // 5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
        new Thread(futureTask).start();


        // 接收返回值
        try {
            // 6.获取Callable中call方法的返回值
            // get()返回值即为FutureTask构造器参数Callable实现类重写的call()的返回值。
            Object sum = futureTask.get();
            System.out.println("总和为:" + sum);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

线程池

现有问题:

如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了。如果需要执行任务就创建线程,任务结束就销毁线程,这样频繁创建和销毁线程就会大大降低系统的效率,因为创建线程和销毁线程都需要时间。

那么有没有一种办法使得线程可以复用,即执行完一个任务,并不被销毁,而是可以继续执行其他的任务?

思路:

提前创建好多个线程放入线程池中,使用时直接获取线程,线程任务执行完毕就将线程放回线程池中。这样就可以避免线程频繁的创建销毁,实现重复利用,能够有效的提高程序的效率。

线程池

线程池的优势:

  • 提高响应速度(减少了创建新线程和销毁旧线程的时间)
  • 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
  • 便于线程管理
    • corePoolSize:核心池的大小
    • maximumPoolSize:最大线程数
    • keepAliveTime:线程没有任务时最多保持多长时间后会结束生命周期

线程相关 API:

  • JDK5.0 之前,我们必须自定义线程池。从 JDK5.0 开始,Java 内置线程池相关的API。在 java.util.concurrent 包下提供了线程池相关API:ExecutorServiceExecutors
  • ExecutorService:真正的线程池接口。常见子类为 ThreadPoolExecutor
    • void execute(Runnable command) :执行任务 / 命令,没有返回值
    • Future submit(Callable task):执行任务,有返回值
    • void shutdown():关闭连接池
  • Executors:一个线程池的工厂类,通过此类的静态工厂方法可以创建多种类型的线程池对象。
    • Executors.newCachedThreadPool():创建一个可根据需要创建新线程的线程池
    • Executors.newFixedThreadPool(int nThreads); 创建一个可重用的固定线程数的线程池
    • Executors.newSingleThreadExecutor() :创建一个只有一个线程的线程池
    • Executors.newScheduledThreadPool(int corePoolSize):创建一个线程池,它主要用来延迟运行任务,或者定期执行任务
 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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import java.util.concurrent.*;

class NumberThread implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i <= 100; i++) {
            if (i % 2 == 0) {
                System.out.println(Thread.currentThread().getName() + ": " + i);
            }
        }
    }
}

class NumberThread1 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i <= 100; i++) {
            if (i % 2 != 0) {
                System.out.println(Thread.currentThread().getName() + ": " + i);
            }
        }
    }
}

class NumberThread2 implements Callable {
    @Override
    public Object call() throws Exception {
        int evenSum = 0;//记录偶数的和
        for (int i = 0; i <= 100; i++) {
            if (i % 2 == 0) {
                evenSum += i;
            }
        }
        return evenSum;
    }

}

public class ThreadPoolTest {

    public static void main(String[] args) {
        // 1.提供指定线程数量的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;
        //设置线程池的属性
        // System.out.println(service.getClass()); // ThreadPoolExecutor
        service1.setMaximumPoolSize(50); //设置线程池中线程数的上限

        // 2.执行指定的线程的操作。需要提供实现Runnable接口或Callable接口实现类的对象
        service.execute(new NumberThread());//适合适用于Runnable
        service.execute(new NumberThread1());//适合适用于Runnable

        try {
            Future future = service.submit(new NumberThread2()); // 适合使用于Callable
            System.out.println("总和为:" + future.get());
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 3.关闭连接池
        service.shutdown();
    }
}
Licensed under CC BY-NC-SA 4.0
鹅掌草の森已经茁壮生长了
发表了8篇文章 · 总计50.10k字 · 共 0 次浏览
记录任何我想记录的事情。若无特殊说明,则本博客文章均为原创,复制转载请保留出处。
使用 Hugo 构建
主题 StackJimmy 设计