线程、线程池、线程同步和线程安全

344 阅读18分钟

一、一些线程的知识

1、进程和线程的区别

1)基本概念

进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发

线程是进程的子任务,是CPU调度和分派的基本单位用于保证程序的实时性,实现进程内部的并发;线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组指令计数器和处理器状态。每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源

2)区别

  1. 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程。线程依赖于进程而存在。
  2. 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。(资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
  3. 进程是资源分配的最小单位,线程是CPU调度的最小单位
  4. 系统开销: 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/o设备等。因此,操作系统所付出的开销将显著地大于在创建或撤消线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。可见,进程切换的开销也远大于线程切换的开销
  5. 通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。在有的系统中,线程的切换、同步和通信都无须操作系统内核的干预
  6. 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂
  7. 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉
  8. 进程适应于多核、多机分布;线程适用于多核

这篇文章总结的比较全面,点击访问原文!


2、Thread的start()和run()方法的区别

1)区别

Java 的线程是通过 java.lang.Thread 类来实现的。VM 启动时会有一个由主方法所定义的线程。可以通过创建 Thread 的实例来创建新的线程。每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,方法 run() 称为线程体。通过调用 Thread 类的 start() 方法来启动一个线程

2)线程状态

在 Java 当中,线程通常都有五种状态,创建、就绪、运行、阻塞和死亡。

  • 第一是创建状态。在生成线程对象,并没有调用该对象的 start 方法,这是线程处于创建状态。
  • 第二是就绪状态。当调用了线程对象的 start 方法之后,该线程就进入了就绪状态,但是此时线程调度程序还没有把该线程设置为当前线程,此时处于就绪状态。在线程运行之后,从等待或者睡眠中回来之后,也会处于就绪状态。
  • 第三是运行状态。线程调度程序将处于就绪状态的线程设置为当前线程,此时线程就进入了运行状态,开始运行 run 函数当中的代码。
  • 第四是阻塞状态。线程正在运行的时候,被暂停,通常是为了等待某个时间的发生(比如说某项资源就绪)之后再继续运行。sleep,suspend,wait 等方法都可以导致线程阻塞。
  • 第五是死亡状态。如果一个线程的 run 方法执行结束或者调用 stop 方法后,该线程就会死亡。对于已经死亡的线程,无法再使用 start 方法令其进入就绪。

Java Thread 中 run() 与 start() 的区别

3)创建并启动线程

实现并启动线程有两种方法

  • 1、写一个类继承自 Thread 类,重写 run 方法。用 start 方法启动线程
  • 2、写一个类实现 Runnable 接口,实现 run 方法。用 new Thread(Runnable target).start() 方法来启动。

方法一:

//启动线程
MyThread myThread = new MyThread();
myThread.start();

//继承线程类
public class MyThread extends Thread {
    private static final String TAG = "MyThread";

    @Override
    public void run() {
        //线程体
        Log.d(TAG, "MyThead:run()");
    }
}

方法二:

//启动线程
Thread thread = new Thread(new MyRunnable());
thread.start();

//实现Runnable
public class MyRunnable implements Runnable {
    private static final String TAG = "MyRunnable";

    @Override
    public void run() {
        //线程体
        Log.d(TAG, "MyRunnable:run()");
    }
}

3、什么是守护线程

Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。

如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。

但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程(后台服务不能停止)。

如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,怎么办?

答案是使用守护线程(Daemon Thread)。

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM退出时,不必关心守护线程是否已结束。

如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

image.png

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

守护线程

二、线程池

1、线程池的定义和优点

线程池,从字面含义来看,是指管理一组同构工作线程的资源池。线程池是与工作队列密切相关的,其中在工作队列中保存了所有等待执行的任务。工作者线程的任务很简单:从工作队列中获取一个任务,执行任务,然后返回线程池并等待下一个任务。

“在线程池中执行任务”比“为每个线程分配一个任务”优势更多。通过重用现有的线程而不是创建线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。另一个额外的好处是,当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。通过适当的调整线程池的大小,可以创建足够的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。

2、线程池的工作流程

  1. 默认情况下,创建完线程池后并不会立即创建线程, 而是等到有任务提交时才会创建线程来进行处理。(除非调用prestartCoreThread或prestartAllCoreThreads方法)
  2. 当线程数小于核心线程数时,每提交一个任务就创建一个线程来执行,即使当前有线程处于空闲状态,直到当前线程数达到核心线程数。
  3. 当前线程数达到核心线程数时,如果这个时候还提交任务,这些任务会被放到工作队列里,等到线程处理完了手头的任务后,会来工作队列中取任务处理。
  4. 当前线程数达到核心线程数并且工作队列也满了,如果这个时候还提交任务,则会继续创建线程来处理,直到线程数达到最大线程数。
  5. 当前线程数达到最大线程数并且队列也满了,如果这个时候还提交任务,则会触发饱和策略
  6. 如果某个线程的控线时间超过了keepAliveTime,那么将被标记为可回收的,并且当前线程池的当前大小超过了核心线程数时,这个线程将被终止。

3、线程池的工作队列

如果新请求的到达速率超过了线程池的处理速率,那么新到来的请求将累积起来。在线程池中,这些请求会在一个由Executor管理的Runnable队列中等待,而不会像线程那样去竞争CPU资源。常见的工作队列有以下几种,前三种用的最多。

  1. ArrayBlockingQueue:列表形式的工作队列,必须要有初始队列大小,有界队列,先进先出。
  2. LinkedBlockingQueue:链表形式的工作队列,可以选择设置初始队列大小,有界/无界队列,先进先出。
  3. SynchronousQueue:SynchronousQueue不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入SynchronousQueue中, 必须有另一个线程正在等待接受这个元素. 如果没有线程等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建 一个线程, 否则根据饱和策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交 给执行它的线程,而不是被首先放在队列中, 然后由工作者线程从队列中提取任务. 只有当线程池是无解的或者可以拒绝任务时,SynchronousQueue才有实际价值.
  4. PriorityBlockingQueue:优先级队列,有界队列,根据优先级来安排任务,任务的优先级是通过自然顺序或Comparator(如果任务实现了Comparator)来定义的。
  5. DelayedWorkQueue:延迟的工作队列,无界队列。

4、线程池的饱和策略(拒绝策略)

当有界队列被填满后,饱和策略开始发挥作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。(如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略)。饱和策略有以下四种,一般使用默认的AbortPolicy。

  1. AbortPolicy:中止策略。默认的饱和策略,抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
  2. DiscardPolicy:抛弃策略。当新提交的任务无法保存到队列中等待执行时,该策略会悄悄抛弃该任务。
  3. DiscardOldestPolicy:抛弃最旧的策略。当新提交的任务无法保存到队列中等待执行时,则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”策略和优先级队列放在一起使用)。
  4. CallerRunsPolicy:调用者运行策略。该策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者(调用线程池执行任务的主线程),从而降低新任务的流程。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。当线程池的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行(调用线程池执行任务的主线程)。由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任务,从而使得工作者线程有时间来处理完正在执行的任务。在这期间,主线程不会调用accept,因此到达的请求将被保存在TCP层的队列中。如果持续过载,那么TCP层将最终发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载后,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。

5、线程池的线程工厂

每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。在ThreadFactory中只定义了一个方法newThread,每当线程池需要创建一个新线程时都会调用这个方法。Executors提供的线程工厂有两种,一般使用默认的,当然如果有特殊需求,也可以自己定制。

  1. DefaultThreadFactory:默认线程工厂,创建一个新的、非守护的线程,并且不包含特殊的配置信息。
  2. PrivilegedThreadFactory:通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、 AccessControlContext、ContextClassLoader。如果不使用privilegedThreadFactory, 线程池创建的线程将从在需要新线程时调用execute或submit的客户程序中继承访问权限。
  3. 自定义线程工厂:可以自己实现ThreadFactory接口来定制自己的线程工厂方法。

6、线程池的简单封装和使用

1)线程池的创建

Java 为我们提供了 ThreadPoolExecutor 来创建一个线程池,其完整构造函数如下所示:

public ThreadPoolExecutor(int corePoolSize,   //核心线程数
                          int maximumPoolSize,  //最大线程数
                          long keepAliveTime,   //保护时长
                          TimeUnit unit,        //时间单位
                          BlockingQueue<Runnable> workQueue,   //任务队列
                          ThreadFactory threadFactory,     //线程工厂
                          RejectedExecutionHandler handler)     //饱和策略                 

常见的4种线程池 常见的四类线程池分别有 FixedThreadPool、SingleThreadExecutor、ScheduledThreadPool 和 CachedThreadPool,它们其实都是通过 ThreadPoolExecutor 创建的,其参数如下表所示:

参数FixedThreadPoolSingleThreadExecutorScheduledThreadPoolCachedThreadPool
说明固定数量的线程池单线程的线程池定时任务的线程池缓存(动态)线程池
corePoolSizen1corePoolSize0
maximumPoolSizen1Integer.MAX_VALUEInteger.MAX_VALUE
keepAliveTime001060
unitMILLISECONDSMILLISECONDSMILLISECONDSSECONDS
workQueueLinkedBlockingQueueLinkedBlockingQueueDelayedWorkQueueSynchronousQueue
threadFactorydefaultThreadFactorydefaultThreadFactorydefaultThreadFactorydefaultThreadFactory
handlerdefaultHandlerdefaultHandlerdefaultHandlerdefaultHandler
适用场景已知并发压力的情况下,对线程数做限制需要保证顺序执行的场景,并且只有一个线程在执行需要多个后台线程执行周期任务的场景处理执行时间比较短的任务

2)合理地配置线程池

需要针对具体情况而具体处理,不同的任务类别应采用不同规模的线程池,任务类别可划分为 CPU 密集型任务、IO 密集型任务和混合型任务。

  • CPU 密集型任务:线程池中线程个数应尽量少,推荐配置为 (CPU 核心数 + 1)
  • IO 密集型任务:由于 IO 操作速度远低于 CPU 速度,那么在运行这类任务时,CPU 绝大多数时间处于空闲状态,那么线程池可以配置尽量多些的线程,以提高 CPU 利用率,推荐配置为 (2 * CPU 核心数 + 1)
  • 混合型任务:可以拆分为 CPU 密集型任务和 IO 密集型任务,当这两类任务执行时间相差无几时,通过拆分再执行的吞吐率高于串行执行的吞吐率,但若这两类任务执行时间有数据级的差距,那么没有拆分的意义。

3)线程池使用及封装

讲下ScheduledThreadPool,其他线程池比较简单,如下代码:

image.png

线程的返回值

因为线程是后台任务,所以线程是没有返回值的,但是我们可以自定义线程的返回值来判断线程是否执行完,下面是随便写的代码:

image.png

打印结果

 19:56:11.339 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:11.840 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:12.340 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:12.841 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:13.341 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:13.841 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:14.344 29066-29093/com.xxx.testthread D/MainActivity: Callable任务未完成
 19:56:14.846 29066-29093/com.xxx.testthread D/MainActivity: Callable任务完成:result:Finish

封装和使用

可以自己封装一个,也可以用AndroidUtilCode中的ThreadUtils

线程池部分参考了如下文章,表示感谢:

妈妈再也不用担心你不会使用线程池了(ThreadUtils)

使用线程池

线程池详解(ThreadPoolExecutor)

三、线程同步和线程安全

1、volatile

见如下代码,执行run()方法子线程是否能够停止?

public class MyTest {

    private static final String TAG = "MyTest";
    public boolean mIsRunning = true;

    public void run() {
        //开启子线程任务
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (mIsRunning) {
                    Log.d(TAG, "子线程正在执行任务...");
                }
            }
        }).start();
        //主线程延时一段时间
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //在主线程修改变量的值,停止子线程任务(?)
        stopRunning();
    }

    private void stopRunning() {
        mIsRunning = false;
    }

}

这是常见的面试题考验Java的内存模型,答案一般是不能停止。因为:在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!如果希望线程间共享变量就需要使用volatile关键字。上面面试题只需要给变量mIsRunning加上volatile关键字即可。

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。

2、synchronized

见如下代码,执行run()方法是否有一个线程会输出2000?

public class MyTest {
    private static final String TAG = "MyTest";
    public volatile int value = 0;

    public void run() {
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    value++;
                }
                Log.d(TAG, "线程一:value:" + value);
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 1000; i++) {
                    value++;
                }
                Log.d(TAG, "线程二:value:" + value);
            }
        }).start();
    }
}

答案是大多数情况下没有线程输出2000。虽然加了volatile关键字,但是其实value++其实是二行代码:

int tempValue = value + 1;
value = tempValue;

变量的取值和赋值,当线程切换时,无法保证变量操作的原子性。那么如何解决,使用synchronized,并保证多个线程使用的同一把锁

改写上面的代码,提供二个方法。

方法一:

public static final Object lock = new Object();  //创建一把锁,建议定义Object变量,开销最小.这里也可以使用.class作为锁
public volatile int value = 0;

public void run() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {  //将变量操作同步,使用锁时,其他地方不能使用锁,锁使用完会自动释放
                    value++;
                }
            }
            Log.d(TAG, "线程一:value:" + value);
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                synchronized (lock) {  //将变量操作同步,使用锁时,其他地方不能使用锁,锁使用完会自动释
                    value++;
                }
            }
            Log.d(TAG, "线程二:value:" + value);
        }
    }).start();
}

synchronized保证了代码块在任意时刻最多只有一个线程能执行。

方法二:

private AtomicInteger mAtomicInteger = new AtomicInteger(0);  //包装类保证了变量的原子性

public void runAtomic() {
    new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                mAtomicInteger.incrementAndGet();  //API方法,相当于++,但是保证了操作的原子性
            }
            Log.d(TAG, "线程一:Atomic_Result:" + mAtomicInteger.get());
        }
    }).start();

    new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 1000; i++) {
                mAtomicInteger.incrementAndGet();  //API方法,相当于++,但是保证了操作的原子性
            }
            Log.d(TAG, "线程二:Atomic_Result:" + mAtomicInteger.get());
        }
    }).start();
}

AtomicInteger包装类及API自带原子性。

总结:

概括一下如何使用synchronized

  1. 找出修改共享变量的线程代码块;
  2. 选择一个共享实例作为锁;
  3. 使用synchronized(lockObject) { ... }

在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁。

3、锁的使用

1)乐观锁和悲观锁

乐观锁和悲观锁是数据库中的概念。

乐观锁:乐观锁认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去判断之前有没有别的线程更新了这个数据(具体方法可以使用版本号机制和CAS算法)。如果这个数据没有被更新,当前线程将自己修改的数据成功写入。如果数据已经被其他线程更新,则根据不同的实现方式执行不同的操作:重试或者报异常。

悲观锁:对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。Java中,synchronized关键字和Lock的实现类都是悲观锁。

2)死锁

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

java 死锁产生的四个必要条件:

  • 1、互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  • 2、不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  • 3、请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  • 4、循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。下面用java代码来模拟一下死锁的产生。

解决死锁问题的方法是:一种是用synchronized,一种是用Lock显式锁实现。

3)锁的设计和优化

如果一个类多个方法有synchronized关键字,它们使用的是同一把锁,即当前类的对象。这样设计是合理的,因为多个方法中可能有并行操作同一个对象,比如下面这样:

public int x = 0;

public synchronized void runA() {
    x = x + 1;
}

private synchronized void runB() {
    x = x + 2;
}

为了保证x变量的原子性,二个方法使用同一把锁是合理的。

但是有时候如果操作的是多个变量,且仍然使用同一把锁,则会导致操作的效率低下,如下所示:

public int x = 0;
public int y = 0;

public synchronized void runA() {
    x = x + 1;
}

private synchronized void runB() {
    y = y + 2;
}

调用runA方法操作x变量,会导致调用runB方法操作y变量的方法阻塞,因为锁可能没有释放。优化代码就是分开锁:

public int x = 0;
public static final Object lockY = new Object();
public int y = 0;

public synchronized void runA() {
    x = x + 1;
}

private  void runB() {
    synchronized (lockY){
        y = y + 2; 
    }
}

这样它们的操作就互不干涉。

4)ReentrantLock

synchronized是系统获取锁和释放锁,而ReentrantLock用于替代synchronized手动加锁和释放锁。见如下代码:

image.png

synchronized是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock是Java代码实现的锁,我们就必须先获取锁,然后在finally中正确释放锁。

顾名思义,ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。

synchronized不同的是,ReentrantLock可以尝试获取锁:

image.png

使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁。

5)ReentrantReadWriteLock

读和写操作应该是允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。使用ReentrantReadWriteLock可以解决这个问题,它保证:

  • 只允许一个线程写入(其他线程既不能写入也不能读取);
  • 没有写入时,多个线程允许同时读(提高性能)。

见如下简陋代码:

image.png

把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。

使用ReentrantReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。

例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReentrantReadWriteLock

线程池部分参考了以下文章,表示感谢

线程同步及其他多篇

不得不说的乐观锁和悲观锁

Java 实例 - 死锁及解决方法


--个人学习笔记