Java - Thread (线程)

555 阅读18分钟

1. Thread

1.1. 线程状态

  • New 新建 新创建了一个线程对象。
  • Runnable 就绪 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu 的使用权 。
  • Waiting 无限期等待 不分配CPU执行时间,等待其他线程显示的唤醒。以下方法会让线程进入Waiting状态:
  • 没有设置 TimeoutObject.wait()
  • 没有设置 TimeoutThread.join()
  • LockSupport.park()
  • Running 运行 可运行状态 runnable 的线程获得了cpu 时间片 timeslice ,执行程序代码。
  • Timed Waiting 限期等待 同上,但是在一定时间后会由系统唤醒。以下方法会让线程进入Waiting状态:
  • Thread.sleep()。
  • 设置Timeout的Object.wait()。
  • 设置Timeout的Thread.join()。
  • LockSupport.parkNanos()。
  • LockSupport.parkUntil()。
  • Blocked 阻塞 指线程因为某种原因放弃了cpu 使用权,也即让出了cpu timeslice,暂时停止运行。直到线程进入可运行(runnable)状态,才有机会再次获得cpu timeslice 转到运行(running)状态。 
  • 等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
  • 同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池(lock pool)中。
  • 其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。
  • Terminated 结束  执行结束。
  • Dead 死亡 线程 run() main() 方法执行结束,或者因异常退出了 run() 方法,则该线程结束生命周期。死亡的线程不可再次复生。

thread.png

1.2. 线程安全

1.2.1. 原子性

Atomicity 

  对基本数据类型的读取和赋值操作是原子性操作,所谓原子性操作就是指这些操作是不可中断的,成功或者失败。只有简单的读取,赋值是原子操作,还只能是用数字赋值,用变量的话还多了一步读取变量值的操作。有个例外是,虚拟机规范中允许对64位数据类型(long和double),分为2次32为的操作来处理,但是最新JDK实现还是实现了原子操作的。JMM只实现了基本的原子性,例如“i++”操作,必须借助于synchronized和Lock来保证整块代码的原子性了。线程在释放锁之前,必然会把i的值刷回到主存的。

1.2.2. 可见性

Visibility 

  利用volatile来提供可见性,变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值,而普通变量则不能保证这一点。通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。

1.2.3. 有序性

Ordering 

  lock unlock volatile 关键字可以产生内存屏障,防止指令重排序时越过。JMM是允许编译器和处理器对指令重排序,规定了as-if-serial语义,即不管怎么重排序,程序的执行结果不能改变。
  加上volatile关键字,禁止重排序,可以确保程序的“有序性”,也可以上重量级的 synchronized 和 Lock 来保证有序性,它们能保证那一块区域里的代码都是一次性执行完毕的。
  JMM具备一些先天的有序性,即不需要通过任何手段就可以保证的有序性,通常称为happens-before原则。<<JSR-133:Java Memory Model and Thread Specification>>定义规则:

  • 程序顺序规则   一个线程中的每个操作,happens-before于该线程中的任意后续操作。
      一个线程中所有操作都是有序的,在JMM中只要执行结果一样,是允许重排序的,happens-before强调的重点也是单线程执行结果的正确性,但是无法保证多线程。

  • 监视器锁规则

  对一个线程的解锁,happens-before于随后对这个线程的加锁。在加锁之前,确定这个锁之前已经被释放了,才能继续加锁。

  • volatile变量规则

  对一个volatile域的写,happens-before于后续对这个volatile域的读。一个线程先去写一个变量,另外一个线程再去读,那么写入操作一定在读操作之前。

  • 传递性

  如果A happens-before B ,且 B happens-before C, 那么 A happens-before C。

  • start() 规则

  如果线程A执行操作ThreadB_start()(启动线程B) , 那么A线程的ThreadB_start() happens-before 于B中的任意操作。

  • join() 原则

  如果A执行ThreadB.join()并且成功返回,那么线程B中的任意操作happens-before于线程A从。ThreadB.join()操作成功返回。

  • interrupt() 原则

  对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。

  • finalize() 原则

  一个对象的初始化完成先行发生于它的finalize()方法的开始。

1.3. 线程调度

  线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度 Cooperative Threads-Scheduling 和 抢占式线程调度 Preemptive Threads-Scheduling 。
  协同式线程调度是一条线程在执行完毕以后,主动通知系统切换到另一条线程上,实现简单,不存在线程同步问题。但是有一个弊端,当由于某些原因导致单条线程阻塞,那有可能导致系统崩溃;抢占式线程调度是每个线程的执行时间由系统来控制,这样线程之间不会相互影响进度。java就是通过这种方式进程线程调度的。
  java可以通过设置线程优先级来决定哪些线程可以优先获取CPU使用权,但是这样的设置也不是一定有效的,取决于java中的优先级与系统的优先级的匹配度。Java中一共设置了十个优先级,如果操作系统的优先级比java的优先级少,那一定存在java的某些优先级是在系统中处于一个级别。

1.4. 内核线程和JVM线程

  在多CPU处理器中,一个cpu可以映射多个KLT指令集,一个KTL又可以映射出多个轻量级线程LWP,KLT和LWP都是系统级的成员,一个轻量级线程又对应着多个用户线程。

image.jpeg

image.jpeg

  LWP Light Weight Process 轻量级进程 / KLT Kernal-Level Thread 内核线程 / UT User Thread 用户线程

1.4.1. 内核线程实现

  直接由操作系统内核支持的线程,内核通过操纵调度器把任务分配到不同的处理器上,这种实现方式可以真正意义上的出去了离多种任务。程序一般会去直接使用内核线程,而是去实现内核线程的一种高级接口——轻量级进程LWP,轻量级进程就是我们通常意义上说的线程,只有先去实现内核线程才能实现轻量级进程,轻量级进程和内核线程是 1:1 关系的线程模型。
  基于内核线程的支持,每个轻量级进程可以单独成为一个调度单元,即使某个轻量级进程在系统调用中被阻塞了,也不会影响系统的整体的工作进程 但是这种实现方式也有巨大的局限性。 由于其是基于系统内核线程支持的,所以:

  • 他的数量有限,对于处理需要大量线程处理的任务是无法实现的。
  • 线程的创建、析构、同步、等操作都要涉及系统内核线程的内核态和用户态切换,对于资源的消耗无疑是巨大的。

1.4.2. 用户线程实现

  1:N 用户线程的实现是指线程的创建、调度、销毁都是在用户态完成,无需内核知道,这种实现方式可以非常快速而且低耗,很多数据库线程就是基于用户线程实现的,其优势在于不需要基于系统内核线程,劣势也在于其不是基于系统内核线程的,系统内核线程只是把资源分配到进程这一级,所以对于 死锁 资源阻塞 如何分配任务给其他处理器等是非常困难的甚至是无法完成的。所以很多种语言放弃了这种实现方式。

1.4.3. 混合实现

  N:M Solaries系统是基于 unix 实现的,他的多线程实现是基于既有内核线程的实现也含有用户线程,用户线程的 创建 析构 销毁 还是 用户态 ,仍然可以支持大规模的用户线程并发,操作系统提供的轻量级进程为用户线程和内核线程之间架起一座桥梁,为处理器映射提供了条件,由于轻量级线程的使用,似的用户线程的调用可以在轻量级线程内完成,所以很大程度的避免了阻塞

1.5. 指定CPU执行

Thread 类的大部分方法都是 native 方法:

image

  • 铺垫 

  实现线程主要是有三种方式详见 1.4.1 ~ 1.4.3
  HotSpot 虚拟机采用 1:1 模型来实现 Java 线程的。Java 线程是直接映射为一个操作系统原生线程的,中间没有额外的间接结构。HotSpot 虚拟机也不干涉线程的 调度 挂起 唤醒 分配时间片 让哪个处理器核心去执行 等等这些关于线程生命周期、执行的东西都是操作系统干的。内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
  由于是基于内核线程实现的,所以各种线程操作,如 创建 析构 同步 ,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态 User Mode 和内核态 Kernel Mode 中来回切换。每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
  在 Java 中一个方法被声明为 native 方法,绝大部分情况下说明这个方法没有或者不能使用平台无关的手段来实现。说明需要操作的是很底层的东西了,已经脱离了 Java 语言层面的范畴。绑定线程到某个 CPU 上去执行都像是操作系统层面干的事儿, Java 作为高级开发语言, 无法直接操作

  • 解决方案
  • Linux上可以用taskset来把线程绑在某个指定的核上。
  • Java层面上现成的库来利用 taskset 绑核:OpenHFT / Java-Thread-Affinity 。

1.5.1. taskset 

Linux 绑定线程命令:

image

1.5.2. Java-Thread-Affinity 

github.com/OpenHFT/Jav…

  线上机器的抖动在所难免,避免IO线程切换并不仅仅依靠 while(true) ,CPU 级别优化腾出4个核心专门给IO线程使用,避免IO线程时间片争用。Java-Thread-Affinity 实现 绑核 。

  • 依赖 
<dependency>
    <groupId>net.openhft</groupId>
    <artifactId>affinity</artifactId>
    <version>3.2.3</version>
</dependency>
  • main方法 CPU第 5 个线程执行死循环,利用率至100%
public static void main(String[] args) {
    try (AffinityLock affinityLock = AffinityLock.acquireLock(5)) {
        // do some work while locked to a CPU.
        while(true) {}
    }
}
  • 执行日志 
net.openhft.affinity.AffinityLock - No isolated CPUS found, so assuming CPUs 1 to 11 available.
net.openhft.affinity.AffinityLock - Assigning cpu 5 to Thread[main,5,mian] on thread in 33056

源码分析

  项目基于 JNA 调用 DLL 文件,从而实现绑核的需求。

image.png

  • 判断操作系统类型:

net.openhft.affinity.Affinity

image.png

  • 各个平台线程亲和性实现

net.openhft.affinity.IAffinity

image.png

  • 例如 Windows 实现类 WindowsJNAAffinity 里面,静态代码块里面调用逻辑:

net.openhft.affinity.impl.WindowsJNAAffinity.CLibrary

image.png

  通过 JNA 调用 kernel32.dll 文件。在 windows 平台上能使用该功能的一些的基石就是在此。

  • 绑定到指定核心上 CPU 编号是从 0 开始,不建议使用。

net.openhft.affinity.AffinityLock#acquireLock(int)

image.png

net.openhft.affinity.AffinityLock#bind(boolean)

image.png   采用的是 BitSet,想绑定到第几个 CPU 就把第几个 CPU 的位置设置为 true。

  • Windows 调用方法

net.openhft.affinity.impl.WindowsJNAAffinity.CLibrary#SetThreadAffinityMask

  • 限制线程在哪个 CPU 上运行的 API。

docs.microsoft.com/zh-cn/windo…

image.png

  • Solaris 实现

  因为我们知道,在 Solaris 平台上的 HotSpot 虚拟机,同时支持 1:1 和 N:M 的线程模型。那么按理来说得提供两套绑定方案,于是我点进去一看,大道至简,直接来一个不实现。 image.png

  • Netty 使用该库

ifeve.com/thread-affi…

image.png

  • SOFAJRaft 依赖了这个包

github.com/sofastack/s…

image.png

image.png

2. ThreadLocal

2.1. 作用

  ThreadLocal是解决线程安全问题一个很好的思路,它通过为每个线程提供一个独立的变量副本解决了变量并发访问的冲突问题。在很多情况下,ThreadLocal比直接使用synchronized同步机制解决线程安全问题更简单,更方便,且结果程序拥有更高的并发性。
  在每个线程Thread内部有一个 ThreadLocal.ThreadLocalMap 类型的成员变量threadLocals,这个threadLocals就是用来存储实际的变量副本的,键值为当前ThreadLocal变量,value为变量副本(即T类型的变量)。 初始时,在Thread里面,threadLocals为空,当通过ThreadLocal变量调用get()方法或者set()方法,就会对Thread类中的threadLocals进行初始化,并且以当前ThreadLocal变量为键值,以ThreadLocal要保存的副本变量为value,存到threadLocals。 然后在当前线程里面,如果要使用副本变量,就可以通过get方法在threadLocals里面查找。

  1. 实际的通过ThreadLocal创建的副本是存储在每个线程自己的threadLocals中的;
  2. 为何threadLocals的类型ThreadLocalMap的键值为ThreadLocal对象,因为每个线程中可有多个threadLocal变量,就像上面代码中的longLocal和stringLocal;
  3. 在进行get之前,必须先set,否则会报空指针异常;如果想在get之前不需要调用set就能正常访问的话,必须重写initialValue()方法。 因为在上面的代码分析过程中,我们发现如果没有先set的话,即在map中查找不到对应的存储,则会通过调用setInitialValue方法返回i,而在setInitialValue方法中,有一个语句是 T value = initialValue() ,而默认情况下,initialValue方法返回的是null。

2.2. 内存泄漏

  ThreadLocal中Key为弱引用; 当ThreadLocal对象(假设为ThreadLocal@123456)的引用被回收了,ThreadLocalMap本身依然还持有ThreadLocal@123456的强引用,如果没有手动删除这个key,则ThreadLocal@123456不会被回收,所以只要当前线程不消亡,ThreadLocalMap引用的那些对象就不会被回收,可以认为这导致Entry内存泄漏。
  ThreadLocal中Value为强引用;gc过后value便消失了,那就无法使用ThreadLocalMap来达到存储全线程变量的效果了。(再次访问该key的时候,依然能取到value,此时取得的value是该value的初始值。即在删除之后,如果再次访问,取到null,会重新调用初始化方法。)
  弱引用一定程度上回收了无用对象,但前提是开发者手动清理掉ThreadLocal对象的强引用。只要线程一直不死,ThreadLocalMap的key-value一直在涨。当某个ThreadLocal变量不再使用时,删除该key(remove())。
  强引用 :普通的引用,强引用指向的对象不会被回收;
  软引用 :仅有软引用指向的对象,只有发生gc且内存不足,才会被回收;
  弱引用 :仅有弱引用指向的对象,只要发生gc就会被回收。

3. ThreadPool

3.1. 线程池深度

image.jpeg

Ncpu CPU核心数。

可通过 Runtime.getRuntime().availableProcessors() 获取

Ucpu 期望CPU使用率,介于 0 ~ 1

W/C 等待时间与计算时间的比率。

3.2. 线程池类型

  1. newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理所需,可灵活回收空闲线程,若线程数不够,则新建线程。

  2. newFixedThreadPool:创建一个固定大小的线程池。可控制并发的线程数量,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。

  3. newSingleThreadExecutor:创建一个单线程的线程池,即只创建唯一的工作者线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

  4. newScheduleThreadPool:创建一个定长的线程池,支持定时及周期性任务执行。

3.3. ThreadPoolExecutor

  线程池执行者类是线程池做那个最核心的一个类,提供四个构造方法,前面三个都是构造器的初始化工作。

public class ThreadPoolExecutor extends AbstractExecutorService {
    //...
    public ThreadPoolExecutor(int corePoolSize , int maxmunPoolSize , long keepAliveTime , TimeUnit unit , TiBlockingQueue<Runnable> workQueue);
    
    public ThreadPoolExecutor(int corePoolSize , int maxmumPoolSize , long keepAliveTime , TimeUnit unit , TiBlockingQueue<Runnable> workQueue , ThreadFactory threadFactory);
    
    public ThreadPoolExecutor(int corePoolSize , int maxmumPoolSize , long keepAliveTime , TimeUnit unit , TiBlockingQueue<Runnable> workQueue , RejectedExecutionHandler handler); 
    
    public ThreadPoolExecutor(int corePoolSize , int maxmumPoolSize , long keepAliveTime , TimeUnit unit , TiBlockingQueue<Runnable> workQueue , ThreadFactory threadFactory , RejectedExecutionHandler handler);
    //...
}
  • corePoolSize 线程池大小,创建线程池后默认情况下,线程池中没有任何线程,而是等待有任务到来才创建线程去执行任务,除非调用prestartAllCoreThread()prestartCoreThread()方法,这两个方法是预创建线程,即在没有任务到来之前就创建 corePoolSize 个线程或 0 个线程。默认情况下,在创建了线程池后,线程池中的线程数目达到了 corePoolSize 后,就会把额外的任务放到缓存队列当中

  • maxmunPoolSize 线程池最大线程数,表示线程池中最多能创建多少个线程。

  • keepAliveTime 线程池没有任务执行时最多保存多久时间会终止。默认情况下,只有当线程池中的线程数大于corePoolSize时,keepAliveTime才会起作用,直到线程池中的线程数不大于corePoolSize,即当线程池中的线程数大于 corePoolSize 时,如果有一个线程空闲时间达到了keepAliveTime,则会终止,直到线程池中的线程数不大于corePoolSize为止。但是如果调用了 allowCoreThreadTimeOut(boolean) 方法,在线程池中的线程数不大于 corePoolSize 是 keepAliveTime 也会起作用,直到线程池中的线程数为 0 。

  • unit keepAliveTime单位,七种静态属性。

  • TimeUnit.DAYS 天
  • TimeUnit.HOURS 小时
  • TimeUnit.MINUTES 分钟
  • TimeUnit.SECONDS 秒
  • TimeUnit.MILLISECONDS 毫秒
  • TimeUnit.MICROSECONDS 微秒
  • TimeUnit.NANOSECONDS 纳秒
  • workQueue 一个阻塞队列,用来存储等待执行的任务,一般来说,阻塞队列有以下几种选择:
  • ArrayBlockingQueue 
  • LinkedBlockingQueue 
  • SynchronousQueue 

  一般使用 LinkedBlockingQueue 和 SynchronousQueue 。线程池的排队策略和 BlockingQueue 有关。

  • threadFactory 线程工厂名主要用来创建线程。
  • handler 当前拒绝处理任务是的策略,有以下几种取值:
  • ThreadPoolExecutor.AbortPolicy 丢弃任务并且抛出RejectedExecutionException异常。
  • ThreadPoolExecutor.DiscardPolicy 也是丢弃任务,但是不抛出异常。
  • ThreadPoolExecutor.DiscardOldestPolicy 丢弃队列最前面的任务,然后重新尝试执行任务(重复执行此任务
  • ThreadPoolExecutor.CallerRunsPolicy 由调用线程处理任务。

示例

public class TaskThreadPool {
    // 线程池大小
    private final static int COREPOOLSIZE = 10;
    // 线程池最大容量
    private final static int MAXPOOLSIZE = 10;
    // 线程空闲时存活最大时长
    private final static long KEEPALIVETIME = 5;
    // 存活时间单位 : 秒
    private final static TimeUnit UNIT = TimeUnit.SECONDS;
    // 阻塞队列,队列大小为线程池最大容量
    private static BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(MAXPOOLSIZE);
    // 拒绝策略1:将抛出 RejectedExecutionException.
    private static RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();
    // 创建线程池
    private static ThreadPoolExecutor executor = new ThreadPoolExecutor(COREPOOLSIZE, MAXPOOLSIZE, KEEPALIVETIME, UNIT, queue, handler);
    /**
     * 执行方法,线程池占满时抛出拒绝执行异常
     * @param task
     * @throws RejectedExecutionException
     */
    public static void execute(Runnable task, String taskName) throws RejectedExecutionException {
        executor.allowCoreThreadTimeOut(true);
        // 线程执行采用阻塞策略
        if (TaskThreadManage.valid(taskName) == false) {
            throw new RejectedExecutionException("Last task was not completed");
        }
        executor.execute(task);
    }
}

3.3.1. 层级结构

   Executor(I) 只有 execute 方法,返回值为 void ,参数为 Runnable 类型,用来执行传进来的任务。

   ExecutorService(I) 继承 Executor 接口,并且额外声明 submit invokeAll invokeAny shutDown ...

   AbstractExecutorService(C)实现 ExecutorService 接口,基本实现了 ExecutorService 中的所有方法。

   ThreadPoolExecutor(C)继承 AbstractExecutorService 。

3.3.2. 重要方法

  execute()submit()shutdown()shutdownNow() 

3.4. 线程回收

  任务执行完成,线程却没有被回收, keepalivetime 未生效(线程池中的线程回收满足3个条件)原因如下:

  1. 参数 allowCoreThreadTimeOut 设置为 true 。
  2. 该线程在 keepAliveTime 时间内获取不到任务,即空闲时间内。
  3. 当前线程池大小大于核心线程池大小 corePoolSize 。