学习目标

  • 理解Java内存模型与内存可见性问题
  • 掌握volatile关键字的作用与正确使用场景
  • 了解volatile与synchronized的区别与联系
  • 能够使用volatile实现线程安全的开关控制

1 内存可见性问题

1.1 什么是内存可见性

在多核CPU环境下,每个处理器都有自己的高速缓存。由于处理器的运行速度远大于内存访问速度,为了提高性能,处理器会将运算需要的数据提前缓存在高速缓存中。当程序在运行过程中,会将运算所需要的数据从主内存复制到CPU的高速缓存中,而高速缓存中的数据会在某个时间点刷新到主内存。

内存可见性问题指的是:当多个线程操作共享数据时,彼此无法看到对方线程对共享变量所做的修改。

让我们通过一个简单的例子来理解这个问题:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. public class VisibilityProblemDemo {
  3. // 共享变量
  4. private static boolean flag = false;
  5. public static void main(String[] args) throws InterruptedException {
  6. // 创建线程1,当检测到flag为true时退出循环
  7. Thread thread1 = new Thread(() -> {
  8. System.out.println("线程1启动");
  9. // 当flag为false时,无限循环
  10. while (!flag) {
  11. // 空循环
  12. }
  13. System.out.println("线程1检测到flag变为true,退出循环");
  14. });
  15. // 创建线程2,将flag设置为true
  16. Thread thread2 = new Thread(() -> {
  17. try {
  18. // 休眠1秒,确保线程1先启动
  19. Thread.sleep(1000);
  20. System.out.println("线程2将flag设置为true");
  21. flag = true;
  22. System.out.println("线程2设置完成");
  23. } catch (InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. });
  27. // 启动线程
  28. thread1.start();
  29. thread2.start();
  30. // 等待线程执行完毕
  31. thread1.join();
  32. thread2.join();
  33. }
  34. }

在这个例子中,你可能会觉得线程2将flag设置为true后,线程1应该能够检测到并退出循环。但在某些情况下(特别是在优化编译和多核CPU的环境下),线程1可能永远无法退出循环。这就是内存可见性问题的体现。

提示:如果你运行这个程序,可能在某些机器上线程1确实能够退出循环,但在其他机器上可能会一直循环下去。这取决于JVM的实现、CPU架构以及编译器的优化策略。

1.2 内存可见性问题的原因

内存可见性问题主要由以下几个原因导致:

  1. CPU缓存:每个CPU都有自己的缓存,线程1对变量的修改可能只更新了线程1所在CPU的缓存,而没有及时刷新到主内存。

  2. 编译器优化:为了提高性能,编译器和CPU会对指令进行重排序,可能导致指令的实际执行顺序与代码编写顺序不一致。

  3. JVM内存模型:Java内存模型允许JVM对代码进行各种优化,其中包括将变量存储在寄存器而不是内存中,这会导致其他线程无法及时看到变量的变化。

下面是一个图示,展示了内存可见性问题是如何发生的:

  1. 线程1(CPU核心1) 线程2(CPU核心2)
  2. +----------------+ +----------------+
  3. | 本地缓存: | | 本地缓存: |
  4. | flag = false | | flag = true |
  5. +----------------+ +----------------+
  6. | |
  7. | |
  8. +--------------------------------------------------+
  9. | 主内存: flag = ? |
  10. +--------------------------------------------------+

如图所示,线程2已经在自己的本地缓存中将flag设置为true,但尚未将这个更新刷新到主内存中。同时,线程1仍然使用本地缓存中的旧值(false)。

2 volatile的作用与使用场景

2.1 volatile关键字介绍

volatile是Java提供的一种轻量级的同步机制,它能够保证变量在多线程之间的可见性,但不能保证原子性。

volatile关键字主要有两个作用:

  1. 保证可见性:当一个线程修改了被volatile修饰的变量后,无论这个变量是否被缓存,其他线程都能立即看到最新值。
  2. 禁止指令重排序:volatile关键字会在指令序列中插入内存屏障,禁止特定类型的指令重排序,从而避免由于指令重排序导致的并发问题。

让我们修改前面的例子,使用volatile关键字来解决内存可见性问题:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. public class VolatileVisibilityDemo {
  3. // 使用volatile修饰共享变量
  4. private static volatile boolean flag = false;
  5. public static void main(String[] args) throws InterruptedException {
  6. // 创建线程1,当检测到flag为true时退出循环
  7. Thread thread1 = new Thread(() -> {
  8. System.out.println("线程1启动");
  9. // 当flag为false时,无限循环
  10. while (!flag) {
  11. // 空循环
  12. }
  13. System.out.println("线程1检测到flag变为true,退出循环");
  14. });
  15. // 创建线程2,将flag设置为true
  16. Thread thread2 = new Thread(() -> {
  17. try {
  18. // 休眠1秒,确保线程1先启动
  19. Thread.sleep(1000);
  20. System.out.println("线程2将flag设置为true");
  21. flag = true;
  22. System.out.println("线程2设置完成");
  23. } catch (InterruptedException e) {
  24. e.printStackTrace();
  25. }
  26. });
  27. // 启动线程
  28. thread1.start();
  29. thread2.start();
  30. // 等待线程执行完毕
  31. thread1.join();
  32. thread2.join();
  33. }
  34. }

在这个修改后的版本中,我们使用volatile关键字修饰flag变量。这样,当线程2修改flag的值时,线程1能够立即看到这个变更,从而退出循环。

2.2 volatile的内存语义

为了更深入地理解volatile的工作原理,我们需要了解Java内存模型(JMM)以及volatile的内存语义。

在Java内存模型中,volatile变量的写操作和读操作分别具有以下内存语义:

  • volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。

这种特性保证了,线程A对volatile变量的写入对线程B的读取可见,即线程A写入的值,能够被线程B读取到。

下面是一个图示,展示了volatile变量如何保证内存可见性:

  1. 线程1(CPU核心1) 线程2(CPU核心2)
  2. +----------------+ +----------------+
  3. | 本地缓存: | | 本地缓存: |
  4. | flag = false |<---+ | flag = true |
  5. +----------------+ | +----------------+
  6. |
  7. | | |
  8. | | |
  9. +--------------------------------------------------+
  10. | 主内存: flag = true |
  11. +--------------------------------------------------+
  12. |
  13. |
  14. +--- volatile保证其他线程能看到最新值

2.3 volatile的适用场景

volatile关键字适用于以下场景:

  1. 状态标记:当一个变量作为状态标记时(如开关控制),通常使用volatile修饰。

  2. 双重检查锁定(Double-Checked Locking):在单例模式的双重检查锁定中,使用volatile可以防止由于指令重排序导致的问题。

  3. 独立观察:一个线程写入变量,另一个线程读取变量,两个线程之间没有其他共享变量,这种情况下可以使用volatile。

让我们看一个使用volatile实现的双重检查锁定单例模式:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. public class SafeSingleton {
  3. // 使用volatile修饰instance
  4. private static volatile SafeSingleton instance;
  5. // 私有构造函数
  6. private SafeSingleton() {
  7. System.out.println("创建SafeSingleton实例");
  8. }
  9. // 获取实例的方法
  10. public static SafeSingleton getInstance() {
  11. // 第一次检查
  12. if (instance == null) {
  13. // 同步代码块
  14. synchronized (SafeSingleton.class) {
  15. // 第二次检查
  16. if (instance == null) {
  17. instance = new SafeSingleton();
  18. }
  19. }
  20. }
  21. return instance;
  22. }
  23. public static void main(String[] args) {
  24. // 创建多个线程同时获取实例
  25. for (int i = 0; i < 10; i++) {
  26. new Thread(() -> {
  27. SafeSingleton singleton = SafeSingleton.getInstance();
  28. System.out.println(Thread.currentThread().getName() + " 获取到实例: " + singleton);
  29. }).start();
  30. }
  31. }
  32. }

在这个例子中,我们使用volatile修饰instance变量。这样做的目的是防止指令重排序导致的问题。因为instance = new SafeSingleton()这一行代码实际上包含三个步骤:

  1. 分配对象的内存空间
  2. 初始化对象
  3. 将引用指向分配的内存空间

如果不使用volatile,这三个步骤可能会被重排序,可能导致其他线程在对象还没有完全初始化时就获取到了实例,从而导致错误。

注意:虽然双重检查锁定模式是volatile的一个经典应用场景,但在实际开发中,更推荐使用静态内部类或枚举实现单例模式,因为它们更简单且线程安全。

2.4 volatile不能解决的问题

虽然volatile能够保证可见性和禁止指令重排序,但它不能保证操作的原子性。这意味着,当一个操作需要先读取值,然后修改值,最后写回值的时候,volatile不能保证这个过程是原子的。

例如,多线程环境下的计数器:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. public class VolatileCounterDemo {
  3. // 使用volatile修饰计数器
  4. private static volatile int counter = 0;
  5. public static void main(String[] args) throws InterruptedException {
  6. // 创建10个线程,每个线程将counter递增1000次
  7. Thread[] threads = new Thread[10];
  8. for (int i = 0; i < threads.length; i++) {
  9. threads[i] = new Thread(() -> {
  10. for (int j = 0; j < 1000; j++) {
  11. counter++; // 非原子操作
  12. }
  13. });
  14. threads[i].start();
  15. }
  16. // 等待所有线程执行完毕
  17. for (Thread thread : threads) {
  18. thread.join();
  19. }
  20. // 输出结果
  21. System.out.println("Expected: " + (10 * 1000));
  22. System.out.println("Actual: " + counter);
  23. }
  24. }

在这个例子中,我们使用volatile修饰counter变量,但最终的结果很可能小于10000。这是因为counter++操作不是原子的,它包含三个步骤:读取counter的值、将值加1、将新值写回counter。在多线程环境下,这三个步骤可能会被其他线程的操作打断,导致最终结果不正确。

对于这种需要保证原子性的场景,应该使用synchronized或java.util.concurrent.atomic包中的原子类,如AtomicInteger。

3 volatile与synchronized的区别

volatile和synchronized是Java中常用的两种同步机制,它们有着不同的特性和适用场景。

3.1 可见性与原子性

  • volatile:保证可见性和禁止指令重排序,但不保证原子性。
  • synchronized:保证可见性、原子性和有序性。

3.2 使用范围

  • volatile:只能修饰变量。
  • synchronized:可以修饰方法和代码块。

3.3 性能开销

  • volatile:轻量级,性能较好。
  • synchronized:重量级,会导致线程上下文切换,性能较差(虽然从Java 6开始,synchronized已经进行了许多优化)。

3.4 适用场景对比

  1. 如果只需要保证可见性,使用volatile即可。
  2. 如果需要保证原子性,应该使用synchronized或java.util.concurrent.atomic包中的原子类。
  3. 如果一个变量被多个线程访问,但只有一个线程修改,可以使用volatile保证可见性。
  4. 如果多个线程都需要修改这个变量,需要使用synchronized或其他锁机制保证原子性。

让我们通过一个例子来对比这两种同步机制:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. import java.util.concurrent.atomic.AtomicInteger;
  3. public class SynchronizationComparisonDemo {
  4. // 使用volatile修饰的计数器,不能保证原子性
  5. private static volatile int volatileCounter = 0;
  6. // 使用synchronized保护的计数器
  7. private static int synchronizedCounter = 0;
  8. // 使用AtomicInteger的计数器
  9. private static AtomicInteger atomicCounter = new AtomicInteger(0);
  10. // synchronized方法,确保原子性
  11. private static synchronized void incrementSynchronizedCounter() {
  12. synchronizedCounter++;
  13. }
  14. public static void main(String[] args) throws InterruptedException {
  15. // 创建10个线程,分别递增三种计数器
  16. Thread[] threads = new Thread[10];
  17. for (int i = 0; i < threads.length; i++) {
  18. threads[i] = new Thread(() -> {
  19. for (int j = 0; j < 1000; j++) {
  20. volatileCounter++; // 非原子操作
  21. incrementSynchronizedCounter(); // 使用synchronized保证原子性
  22. atomicCounter.incrementAndGet(); // 使用AtomicInteger保证原子性
  23. }
  24. });
  25. threads[i].start();
  26. }
  27. // 等待所有线程执行完毕
  28. for (Thread thread : threads) {
  29. thread.join();
  30. }
  31. // 输出结果
  32. System.out.println("Expected: " + (10 * 1000));
  33. System.out.println("Volatile Counter: " + volatileCounter);
  34. System.out.println("Synchronized Counter: " + synchronizedCounter);
  35. System.out.println("Atomic Counter: " + atomicCounter.get());
  36. }
  37. }

运行这个程序,你会发现volatileCounter的值很可能小于10000,而synchronizedCounteratomicCounter的值一定是10000。这说明volatile不能保证原子性,而synchronized和AtomicInteger可以保证原子性。

4 volatile的内部原理

为了更深入地理解volatile的工作原理,我们需要了解Java内存模型以及volatile在底层的实现。

4.1 内存屏障(Memory Barrier)

volatile的底层实现主要依赖于内存屏障(Memory Barrier)指令。内存屏障是一种CPU指令,用于控制特定条件下的重排序和内存可见性。

在Java中,volatile通过插入内存屏障来实现以下功能:

  1. 保证可见性:当写一个volatile变量时,会在写操作后插入一个写屏障(Store Memory Barrier);当读一个volatile变量时,会在读操作前插入一个读屏障(Load Memory Barrier)。
  2. 禁止指令重排序:通过内存屏障,确保volatile变量的读写操作不会被重排序。

4.2 happens-before关系

Java内存模型(JMM)定义了一种happens-before关系,用来表示一个操作对另一个操作可见。如果操作A happens-before操作B,那么操作A的结果对操作B可见。

volatile变量的读写建立了happens-before关系:

  • 对一个volatile变量的写操作happens-before后续对这个volatile变量的读操作。

这意味着,当线程A写入一个volatile变量,线程B随后读取这个变量,那么线程A在写入volatile变量之前的所有操作对线程B都是可见的。

4.3 volatile的底层实现

在不同的硬件架构和JVM实现中,volatile的底层实现可能有所不同。但基本原理是一致的:

  1. x86架构:在x86架构上,写操作自动具有释放(release)语义,读操作自动具有获取(acquire)语义,所以volatile的写操作只需要在写后插入一个写屏障,而volatile的读操作不需要插入读屏障。
  2. 其他架构:在其他架构(如ARM)上,可能需要在volatile写后插入写屏障,在volatile读前插入读屏障。

以下是一个简化的示意图,展示了volatile在底层的实现原理:

  1. // 写volatile变量
  2. store value -> volatile variable
  3. StoreStore barrier (防止写操作重排序)
  4. StoreLoad barrier (确保其他处理器能看到该写操作)
  5. // 读volatile变量
  6. LoadLoad barrier (防止读操作重排序)
  7. load value <- volatile variable
  8. LoadStore barrier (防止读操作与后续写操作重排序)

注意:以上是一个简化的示意图,实际实现可能因JVM版本和硬件架构而异。

5 实战案例:使用volatile实现一个简单的缓存系统

下面我们将实现一个简单的缓存系统,使用volatile确保缓存一致性。

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. import java.util.HashMap;
  3. import java.util.Map;
  4. import java.util.concurrent.TimeUnit;
  5. public class SimpleCache {
  6. // 使用volatile修饰缓存对象,确保可见性
  7. private static volatile Map<String, Object> cache = new HashMap<>();
  8. // 模拟从数据库加载数据
  9. private static Object loadFromDB(String key) {
  10. System.out.println("从数据库加载数据:" + key);
  11. try {
  12. // 模拟数据库操作的延迟
  13. TimeUnit.MILLISECONDS.sleep(200);
  14. } catch (InterruptedException e) {
  15. e.printStackTrace();
  16. }
  17. return "Data for " + key;
  18. }
  19. // 从缓存获取数据,如果缓存中没有则从数据库加载
  20. public static Object get(String key) {
  21. // 从缓存中获取数据
  22. Object value = cache.get(key);
  23. if (value == null) {
  24. synchronized (SimpleCache.class) {
  25. // 双重检查,防止多个线程同时加载同一个数据
  26. value = cache.get(key);
  27. if (value == null) {
  28. // 从数据库加载数据
  29. value = loadFromDB(key);
  30. // 更新缓存
  31. Map<String, Object> newCache = new HashMap<>(cache);
  32. newCache.put(key, value);
  33. cache = newCache; // 原子更新整个缓存,确保可见性
  34. }
  35. }
  36. }
  37. return value;
  38. }
  39. // 更新缓存
  40. public static void put(String key, Object value) {
  41. synchronized (SimpleCache.class) {
  42. Map<String, Object> newCache = new HashMap<>(cache);
  43. newCache.put(key, value);
  44. cache = newCache; // 原子更新整个缓存,确保可见性
  45. }
  46. }
  47. // 清除缓存
  48. public static void clear() {
  49. synchronized (SimpleCache.class) {
  50. cache = new HashMap<>();
  51. }
  52. }
  53. public static void main(String[] args) {
  54. // 创建多个线程同时访问缓存
  55. for (int i = 0; i < 10; i++) {
  56. final int index = i;
  57. new Thread(() -> {
  58. Object data = get("key" + (index % 5));
  59. System.out.println(Thread.currentThread().getName() + " 获取数据:" + data);
  60. }).start();
  61. }
  62. }
  63. }

在这个实战案例中,我们使用volatile修饰缓存Map对象,并且在更新缓存时创建一个新的Map对象,而不是直接修改现有的Map。这种方式保证了缓存的一致性和可见性。

注意:虽然这个简单的缓存系统可以工作,但在实际应用中,我们通常会使用ConcurrentHashMap或专业的缓存库如Caffeine、Guava Cache等。

6 常见问题与解决方案

在使用volatile关键字时,有一些常见问题需要注意:

6.1 volatile不保证原子性

问题:volatile变量的复合操作(如i++)不是原子的,可能导致线程安全问题。

解决方案:

  1. 使用synchronized关键字保证原子性。
  2. 使用java.util.concurrent.atomic包中的原子类,如AtomicInteger。

示例:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. import java.util.concurrent.atomic.AtomicInteger;
  3. public class AtomicSolutionDemo {
  4. // 使用AtomicInteger替代volatile int
  5. private static AtomicInteger counter = new AtomicInteger(0);
  6. public static void main(String[] args) throws InterruptedException {
  7. // 创建10个线程,每个线程将counter递增1000次
  8. Thread[] threads = new Thread[10];
  9. for (int i = 0; i < threads.length; i++) {
  10. threads[i] = new Thread(() -> {
  11. for (int j = 0; j < 1000; j++) {
  12. counter.incrementAndGet(); // 原子操作
  13. }
  14. });
  15. threads[i].start();
  16. }
  17. // 等待所有线程执行完毕
  18. for (Thread thread : threads) {
  19. thread.join();
  20. }
  21. // 输出结果
  22. System.out.println("Expected: " + (10 * 1000));
  23. System.out.println("Actual: " + counter.get());
  24. }
  25. }

6.2 过度使用volatile

问题:过度使用volatile可能导致不必要的内存同步,影响性能。

解决方案:

  1. 只在必要的场景下使用volatile。
  2. 对于复杂的同步需求,考虑使用java.util.concurrent包中的工具类。

6.3 volatile与单例模式

问题:在双重检查锁定单例模式中,如果不使用volatile修饰instance变量,可能会因为指令重排序导致问题。

解决方案:

  1. 使用volatile修饰instance变量。
  2. 考虑使用静态内部类或枚举实现单例模式,这些方式更简单且线程安全。

示例(静态内部类实现单例):

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. public class StaticInnerClassSingleton {
  3. // 私有构造函数
  4. private StaticInnerClassSingleton() {
  5. System.out.println("创建StaticInnerClassSingleton实例");
  6. }
  7. // 静态内部类,持有单例实例
  8. private static class SingletonHolder {
  9. private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
  10. }
  11. // 获取实例的方法
  12. public static StaticInnerClassSingleton getInstance() {
  13. return SingletonHolder.INSTANCE;
  14. }
  15. public static void main(String[] args) {
  16. // 创建多个线程同时获取实例
  17. for (int i = 0; i < 10; i++) {
  18. new Thread(() -> {
  19. StaticInnerClassSingleton singleton = StaticInnerClassSingleton.getInstance();
  20. System.out.println(Thread.currentThread().getName() + " 获取到实例: " + singleton);
  21. }).start();
  22. }
  23. }
  24. }

6.4 volatile数组

问题:当使用volatile修饰数组时,只有数组引用是volatile的,数组元素不是volatile的。

解决方案:

  1. 使用AtomicReferenceArray。
  2. 对数组元素的访问加锁。
  3. 考虑使用CopyOnWriteArrayList等线程安全的集合类。

示例:

  1. package org.devlive.tutorial.multithreading.chapter06;
  2. import java.util.concurrent.atomic.AtomicIntegerArray;
  3. public class VolatileArrayDemo {
  4. // 使用volatile修饰数组,只有数组引用是volatile的,数组元素不是
  5. private static volatile int[] volatileArray = new int[10];
  6. // 使用AtomicIntegerArray保证元素的原子性
  7. private static AtomicIntegerArray atomicArray = new AtomicIntegerArray(10);
  8. public static void main(String[] args) throws InterruptedException {
  9. // 创建10个线程,每个线程操作不同索引的元素
  10. Thread[] threads = new Thread[10];
  11. for (int i = 0; i < threads.length; i++) {
  12. final int index = i;
  13. threads[i] = new Thread(() -> {
  14. for (int j = 0; j < 1000; j++) {
  15. // 对volatileArray[index]进行非原子的自增操作
  16. volatileArray[index]++;
  17. // 对atomicArray[index]进行原子的自增操作
  18. atomicArray.incrementAndGet(index);
  19. }
  20. });
  21. threads[i].start();
  22. }
  23. // 等待所有线程执行完毕
  24. for (Thread thread : threads) {
  25. thread.join();
  26. }
  27. // 输出结果
  28. boolean volatileArrayCorrect = true;
  29. boolean atomicArrayCorrect = true;
  30. for (int i = 0; i < 10; i++) {
  31. if (volatileArray[i] != 1000) {
  32. volatileArrayCorrect = false;
  33. }
  34. if (atomicArray.get(i) != 1000) {
  35. atomicArrayCorrect = false;
  36. }
  37. }
  38. System.out.println("Expected value for each element: 1000");
  39. System.out.println("Volatile Array correct: " + volatileArrayCorrect);
  40. System.out.println("Atomic Array correct: " + atomicArrayCorrect);
  41. }
  42. }

6.5 volatile的性能考量

问题:虽然volatile比synchronized轻量级,但过度使用仍会影响性能。

解决方案:

  1. 在性能关键的代码中,谨慎使用volatile。
  2. 考虑使用Java 8引入的StampedLock或其他高性能同步工具。
  3. 进行性能测试,确定最适合的同步机制。

7 小结

在本章中,我们深入学习了Java中的volatile关键字,主要内容包括:

  1. 内存可见性问题:了解了多线程环境下的内存可见性问题及其产生原因。

  2. volatile的作用:掌握了volatile的两个主要作用:保证可见性和禁止指令重排序。

  3. volatile的适用场景:学习了volatile的适当使用场景,包括状态标记、双重检查锁定等。

  4. volatile的局限性:认识到volatile不能保证操作的原子性,对于需要原子性的操作,应该使用synchronized或原子类。

  5. volatile与synchronized的区别:比较了这两种同步机制的异同点,了解它们各自的适用场景。

    实战案例:通过实例展示了如何使用volatile实现线程安全的开关控制和简单的缓存系统。

  6. 常见问题与解决方案:介绍了使用volatile时的常见问题及其解决方法。

volatile是Java并发编程中的一个重要工具,它提供了一种轻量级的同步机制。合理使用volatile可以在某些场景下避免使用重量级的synchronized关键字,从而提高程序性能。但需要注意,volatile并不适用于所有场景,特别是需要保证操作原子性的场景。

在下一章中,我们将学习ThreadLocal的使用,它是另一种重要的线程安全机制,用于实现线程隔离。

源代码地址:https://github.com/qianmoQ/tutorial/tree/main/java-multithreading-tutorial/src/main/java/org/devlive/tutorial/multithreading/chapter06