关于java:Java并发编程线程

10次阅读

共计 10634 个字符,预计需要花费 27 分钟才能阅读完成。

Java 内存模型

Java 内存模型(JMM)是一个中间层的模型,是物理内存模型的映射,它为程序员屏蔽了底层的硬件实现细节(CPU 缓存一致性及内存屏障等问题),也屏蔽操作系统的内存拜访差别,以实现 Java 程序在各种平台下都能达到统一的内存拜访成果。Java 内存模型如下图所示:

Java 线程对变量的所有操作都在各自的工作内存(主内存的正本)中进行,不能间接读写主内存(volatile 变量也不例外),也不能读写其余线程的工作内存。

内存可见性

内存可见性是指,一个线程对变量的批改可能立刻被其余线程感知到,即可能立刻读取到最新值。可见性问题的起源是 Java 内存模型里各线程的工作内存,同时因为指令重排序的存在,使可见性谬误总是违反咱们的直觉。为确保多个线程之间对内存写入操作的可见性,必须应用同步机制。比方 volatile 润饰,或者应用锁。
以下程序:线程 1 读取变量 run 来管制循环,线程 2 用来扭转 run 的值为 false。但线程 1 始终无奈读取到线程 2 对变量 run 更改后的值,导致线程 1 的循环无奈完结。

public class VisibleTest {
    static boolean run = true;
    static int other = 1; 

    public static void main(String[] args) {
        // 线程 1
        Thread thread1 = new Thread(() -> {
            int loop = 0;
            while (true) {
                // 第 10 行
                if (!run) {break;}
                loop += 1;
                other = 2;
            }
            System.out.println(String.format("T1 end, run=%s, loop=%s, other=%s, time=%d", run, loop, other, System.currentTimeMillis()));
        });
        thread1.start();
        
        // 线程 2
        Thread thread2 = new Thread(() -> {
            try {Thread.sleep(20);
            } catch (InterruptedException e) {e.printStackTrace(); }
            run = false;
        });
        thread2.start();
        
        // 主线程
        System.out.println(String.format("mainT run=%s, other=%s, time=%d",  run, other, System.currentTimeMillis()));
    }
}

使线程 1 能够读取到线程 2 对变量 run 的更新值 (即让线程 1 完结循环)的办法,有以下几种:
1. 变量 run 减少 volatile 润饰;
2. 变量 other 减少 volatile 润饰;
3. 在第 10 行减少:System.out.println(“-“); 使产生 IO 操作。
4. 在第 10 行减少:Thread.yield(); 让 cpu 从新抉择执行的线程(以后线程也可能被再次选中),以触发线程切换。
5. 在第 10 行减少:一个 synchronized 办法的调用。以产生获取锁和开释锁的操作。上述办法中的任何一个都能够让线程 1 完结循环并退出。
这里波及一个问题,什么时候工作内存的变量会生效?答案是:1. 线程开释锁时;2. 线程切换时;3.cpu 有闲暇工夫时,比方线程休眠,IO 操作。工作内存的变量生效后,线程会从主存读取此变量的值。

Happens-Before 规定

编译器生成的指令程序能够与源代码中的程序不同。同时,为了进步执行效率,处理器能够采纳乱序或并行等形式来执行指令。这些乱序操作的前提是,程序的最终后果与在严格串行环境中执行的后果雷同。
JMM 为程序中的操作提供了一个 Happens-Before 规定,它是一个原生的规定,无需应用任何同步伎俩就曾经存在,由 JVM 保障。如果操作 A 和操作 B 满足这个规定,那么操作 A 肯定先于 B 执行,并且 A 的执行后果对 B 可见(即 B 能察看到 A 产生的影响)。如果两个操作 A 和 B 没有 Happens-Before 关系,那么 JVM 能够对它们进行任意地重排序,执行后果是无奈预测的。这些规定包含:
1. 程序程序规定 。在一个线程内,操作 A 和 B 按书写的逻辑程序执行;
2. 监视器锁规定。同一个监视器锁上的解锁操作必须在加锁操作之前执行;
3.volatile 变量规定。对 volatile 变量的写入操作必须在对该变量的读取之前执行;
4. 线程启动规定。Thread 对象的 start 办法必须在该线程其余任何操作之前执行;
5. 线程完结规定。线程中的任何操作都必须在其余线程能检测到该线程已完结之前执行。该线程已完结的标记是从 Thread.join() 返回,或者 Thread.isAlive()返回 false;
6. 线程中断规定 。当一个线程 A 调用另一个线程 B 的 interrupt() 办法时,此办法必须在线程 B 检测到中断事件之前执行。
7. 对象终结规定 。对象的构造函数必须在启动该对象的终结器 finalize() 之前执行实现;
8. 传递性。如果操作 A 在 B 之前执行,并且操作 B 在 C 之前执行,那么操作 A 必须在 C 之前执行。

通过 Happens-Before 规定,能够剖析并发环境下两个操作是否可能存在抵触,是否存在安全性问题。Happens-Before 规定与工夫先后顺序没有因果关系,在剖析线程平安问题时不要受工夫程序的烦扰

工作内存和主存之间的数据同步

问题:一个线程何时会从主存中去从新读取共享变量的值,又是何时须要将工作内存的值从新刷写到主存中。
前提:不应用 volatile 关键字保障内存可见性的状况。因为 volatile 标识的变量通知 JVM 此变量是易变的,使线程工作内存的值总是生效而必须每次都从主存取值。
工作内存的值写入主存的机会 :线程完结,线程开释锁,线程切换,抛出异样等状况。
线程从新读取主存的值到工作内存的机会:线程切换,获取锁(尽管锁操作只对同步代码块起到作用,但影响的却是线程执行所应用的所有字段),cpu 有闲暇工夫(比方线程 sleep,IO 操作等)。

线程

线程是 Java 里进行 CPU 资源调度的最小单位。Java Thread 类的要害办法都是 native 办法,意味着 Java 线程是与操作系统平台相干的。

线程的实现

Java 线程如何实现并不受 Java 虚拟机标准的束缚,虚拟机厂商自行决定。目前支流的 Java 虚拟机的线程模型都是基于操作系统原生线程模型来实现的,Java 线程与操作系统的内核线程是 1:1 的映射关系。JVM 不会干预线程的调度和执行,全权交给操作系统去解决
Java 线程有 6 种状态(Thread.State 枚举类定义):new, runnable(蕴含 ready 和 running), blocked, timed_waiting, waiting, terminated。线程的状态转换图如下:

协程

协程 (coroutine),是协同式调度的用户线程(非内核线程),分有栈 / 无栈协程。
协程的呈现是因为操作系统内核线程有其人造缺点:线程切换和调度的老本昂扬,即响应中断、爱护和复原执行现场的老本昂扬。零碎能包容的线程数量也无限(在几百和上千的量级)。协程的次要劣势是轻量 ,一个协程的栈大小通常在几百字节到几 KB(而线程的默认内存大小是 1M),零碎能包容几万量级的协程。
纤程(Fiber),是一种有栈协程的特例实现,由 JVM 调度而非操作系统调度,由 OpenJDK Loom 我的项目开发。

线程的开销

有以下 4 种开销:

  • 线程创立 。第 1 步 new Thread() 做初始化操作,第 2 步 thread.start()调用操作系统 API 创立 native thread。同时也有对内存的开销,通过 -Xss 设置线程堆栈内存大小,默认值 1M。当线程被应用时才会真正耗费物理内存,否则只调配给虚拟内存。线程创立和启动约 70-80 微秒。
  • 线程切换。也叫上下文切换,须要保留和复原执行现场。当一个新线程被切换进来时,它所须要的数据可能不在以后处理器的缓存中,因而线程在首次运行时会更迟缓。调度器会为每个线程调配一个最小执行工夫,以摊派上下文切换的开销,从而进步整体的吞吐量,但毛病是损失了响应性。一次线程切换的耗时约几微秒,切换次数能够通过 vmstat 命令查看(unix 零碎)。
  • 内存同步。synchronized 和 volatile 提供的可见性保障中可能会应用内存栅栏(Memory Barrier),它会刷新缓存,使缓存有效,同时会克制一些编译器优化操作,使大多数操作不能被指令重排,从而对性能带来间接影响。同步还会减少共享内存总线上的通信量,而所有处理器都将共享这条总线,从而影响其余线程的性能。如果是无竞争的同步,那么它对性能的影响微不足道,能够疏忽它。关注重点应该放在有竞争的同步。
  • 阻塞。当在锁上产生竞争时,竞争失败的线程必定会阻塞。JVM 在实现阻塞时能够采纳自旋期待(Spin-waiting)或者通过操作系统挂起线程。如果等待时间短,自旋期待的效率高;反之,则线程挂起的效率高。自旋期待减少了 CPU 时钟的开销,挂起则减少了两次额定的上下文切换开销。

线程创立开销:https://lotabout.me/books/Jav…
上下文切换开销:https://eli.thegreenplace.net…

线程平安

在没有短缺同步的状况下,多个线程中的操作的执行程序是不可预测的,会产生安全性问题。一开始就设计一个线程平安的类十分重要,因为这比当前再将这个类批改为线程平安的类要容易得多。

什么是线程安全性

线程安全性 :当多个线程拜访某个类时,这个类始终都能体现出正确的行为,那么就称这个类是线程平安的。
正确性的含意 是,某个类的行为与其标准完全一致。
哪些是不平安的代码:

  • Race Condition(竞态条件 / 竞争态势):并发编程中,因为多个线程不失当的执行时序而呈现不正确的后果。常见的 race condition 比方:多线程执行 count++,因为自增运算不是原子操作,它蕴含 3 个独立地操作:读取 - 批改 - 写入。线程读取到的数值可能是后面 3 个操作中的任意一步的值,所以它的最终后果是不可预测的。
  • 在没有同步的状况下,编译器、处理器和运行时等都可能对操作的执行程序进行一些意想不到的调整,即“指令重排”
  • 非 volatile 的 long 和 double 变量,JVM 容许将 64 位的读操作或写操作合成为两个 32 位的操作,即如果对该变量的读写操作在不同的线程中执行,那么很可能会读取到某个值的高 32 位和另一个值的低 32 位。因而在多线程程序中应用共享且可变的 long 和 double 变量是不平安的,保障其安全性须要加锁爱护或者用 volatile 润饰。对象逸出:某个不该公布的对象被公布。
  • 对象逸出 导致其余线程拿到半成品对象的援用,从而引发安全性问题。

如何保障安全性

  • 无状态对象肯定是线程平安的。
  • 加锁
    每个 Java 对象都能够用作锁,它称为”内置锁“或监视器锁。内置锁是可重入的。synchronized 润饰的办法,它用的锁就是办法所在的对象,动态的 synchronized 办法以 Class 对象作为锁。加锁也能够保障变量的内存可见性。
  • 内存可见性
    volatile 变量,确保变量的更新操作被其余线程可见,是一种轻量级的同步机制(在大多数处理器架构上,读取 volatile 变量的开销只比读取一般变量略高一些)。
    编译器和运行时都会留神到 volatile 变量是共享的,不会在该变量上做指令的重排序,也不会缓存该变量在寄存器或对其余处理器不可见的中央,因而 volatile 变量的最新值总会被所有线程可见。
  • 对象的平安公布
    公布(Publish)一个对象是指,使对象可能在以后作用域之外的代码中应用。
    逸出(Escape)指某个不该公布的对象被公布了。
    公布外部状态可能会毁坏封装性,并使程序难以维持不变性条件。
    不变性条件 (invariant):不同变量之间的束缚关系。
    前置条件 (pre-conditions):在调用该办法或代码块之前,该条件必须为 true;
    后置条件(post-conditions):在调用该办法或代码块之后,该条件必须为 true;
  • 线程关闭
    线程关闭(Thread Confinement),是指不共享数据,线程独享一份数据。这是实现线程安全性最简略的形式之一。
    线程关闭技术的常见利用是 JDBC 的 Connection 对象,它不是线程平安的,但各个服务线程独享一个 Connection 对象。
    栈关闭,是线程关闭的一种特例,只能通过局部变量能力拜访对象。根本类型的局部变量始终关闭在线程内。
    ThreadLocal 类,是一种更标准的线程关闭办法。它用于保留一个线程独享的值。ThreadLocal 提供了 get 和 set 办法。
  • 应用不可变对象
    不可变对象肯定是线程平安的。满足以下条件时,对象才是不可变的:1. 对象创立当前其状态就不能批改;2. 对象的所有域都是 final 类型;3. 对象是正确创立的(对象创立期间,this 援用没有逸出)。

    并发工作

    工作的提交和执行

    工作执行多个并发工作通常应用执行器来执行,它能复用线程资源,把工作的创立和执行拆散,使程序员能专一于工作的创立。执行器提供了两种工作提交办法:
    1. 提交无返回值的工作,execute();工作实现 Runnable 接口;
    2. 提交有返回值的工作,submit();工作实现 Callable 接口,返回值封装在 Future 接口中;

    执行器的类间关系

执行器

  • Executor 接口
    形象了工作的执行者,它会负责创立线程,启动线程,执行工作。接口只有一个办法:execute(Runnable run) – 提交一个无返回值的工作,立刻返回;
  • ExecutorService 接口
    继承了 Executor 接口,新增了更多的工作执行必须的办法:
    Future submit(Callable task) – 提交一个有返回值的工作,立刻返回。外部调用了 execute 办法;
    List invokeAll() – 期待提交的所有工作都返回(或达到超时限度)后才返回。返回值为所有工作的 future;
    T invokeAny() – 提交的工作中只有一个工作胜利后即返回,其余工作均勾销。返回值为胜利的那个工作的值;
    shutdown() – 平缓的敞开过程:不承受新工作,期待运行中的和曾经提交的工作执行结束。它会立刻返回,所以通常须要配合应用 awaitTermination()办法,以阻塞期待;
    List shutdownNow() – 尝试勾销所有运行中的工作,不再启动队列中尚未开始的工作。返回值是尚未开始执行的工作 list。
  • AbstractExecutorService 抽象类
    抽象类 AbstractExecutorService 实现了 ExecutorService 接口。
    ThreadPoolExecutor 继承 AbstractExecutorService 抽象类。
    ThreadPoolExecutor.Worker extends AbstractQueuedSynchronizer implements Runnable,封装执行的工作和线程。线程的复用由 runWorker(Worker w)办法实现,它外部应用 while 循环不断从工作队列(workQueue)取工作给以后线程执行,当取回的工作为 null 时循环完结,线程也就退出。
    ForkJoinPool 继承 AbstractExecutorService 抽象类,相似于单机版 map-reduce 工作原理,把工作合成后并发执行再汇总。
  • CompletionService 接口
    它只有一个实现类 ExecutorCompletionService,用于按工作实现的先后顺序读取工作。其外部实现比较简单,把 Executor 和 BlockingQueue 的性能交融在一起。
  • Executors 类
    是一个工具类,提供了一系列静态方法创立线程池等;
    例如创立固定线程数量的线程池:ExecutorService execService = Executors.newFixedThreadPool(10);

论文(翻译):https://blog.csdn.net/dhaibo1…
ForkJoinPool:https://cloud.tencent.com/dev…

工作的勾销和进行

有时候咱们心愿提前结束工作或线程,比方设置的超时工夫到了,或用户勾销了操作,或应用程序须要被疾速敞开。线程的立刻进行可能会造成共享数据处于不统一的状态(因而没有平安的抢占式办法来立刻进行线程,只有合作式的机制),良好的程序设计须要能欠缺地解决失败,勾销和敞开行为。即须要治理好工作的生命周期。

线程中断

线程中断是最正当的“勾销”形式,它是一种合作机制,能够通过中断来告诉(不是强制)另一个线程进行以后的工作,并转而执行其余工作。当线程 A 中断线程 B 时,A 仅仅是要求 B 在执行到某个能够暂停的中央时进行正在执行的操作。另外须要留神,JVM 不能保障阻塞办法检测到中断的速度
Thread 的中断办法:

public void interrupt(); // 发出请求中断的信息给指标线程,而后指标线程在下一个适合的时刻 (勾销点) 中断本人。public boolean isInterrupted(); // 返回指标线程的中断状态
public static boolean interrupted(); // 革除线程的中断状态并返回它之前的值

中断策略指,当发现中断请求时应该做哪些工作,以及多快的速度来响应中断。最正当的中断策略是,尽快退出,在必要时清理,并告诉线程所有者曾经退出。
哪些工作(代码)能够被中断?
当阻塞办法收到中断请求的时候就会抛出 InterruptedException 异样,从而被中断执行。并非所有的阻塞办法或阻塞机制都能响应中断,比方期待内置锁的阻塞,期待同步的 Socket IO。

非阻塞工作的勾销

1. 线程内应用共享变量(volatile 润饰)isCancel 来频繁检测是否工作勾销,从而管制工作完结。当内部线程设置共享变量 isCancel=true 时,线程能很快发现这个更改,从而采取勾销措施;
2. 线程内频繁检测 Thread.currentThread().isInterrupt(),从而采取勾销措施。

不可中断的阻塞

并非所有的阻塞办法或阻塞机制都能响应中断,比方执行同步的 socket IO,或期待取得内置锁的阻塞。这时心愿勾销运行中的线程,能够通过改写 Thread 类的 interrupt 办法,将非标准的勾销操作(比方敞开 socket)封装在线程中。对于内置锁,能够应用 lock.lockInterruptibley()办法加锁,这个办法可能在取得锁的同时放弃对中断的响应。

计时运行

对工作进行超时设置,如果工作不能在指定的工夫内返回,那么调用者就不再期待。须要留神,当这些工作超时后应该立刻进行(例如 Future.cancel(true)),防止为计算一个不再应用的后果而节约计算资源。编写的工作是否可勾销,在设计时须要注意。

通过 Future 来勾销

Future 有一个 cancel 办法,该办法带有一个 boolean 型参数:mayInterruptIfRunning。为 true,示意如果工作正在运行,那么将被中断;为 false,示意若工作还没启动则不要启动它,但如果工作已在运行是不会中断它的。如果你不晓得线程的中断策略,就不要中断线程。由 Executor 创立的线程,其中断策略能够使工作通过中断勾销,所以如果工作在 Executor 中运行,用 future.cancel(true)来勾销工作是平安的。

线程透露

导致线程提前死亡的最次要起因是 RuntimeException。当一个线程因为未捕捉异样而退出时,JVM 会把这个事件报告给 UncaughtExceptionHandler 处理器,默认解决是把异样栈信息输入到 System.err。
为线程池中所有线程设置一个 UncaughtExceptionHandler,须要为 ThreadPoolExecutor 的构造函数提供一个 ThreadFactory,在线程创立时的 newThread()办法外部进行设置。但只有 execute()提交的工作能力将它抛出的异样交给 UncaughtExceptionHandler,submit()提交的工作的异样会封装在 Future.get()抛出的 ExecutionException 里。

@Override
public Thread newThread(Runnable runnable) {String threadName = String.format("%s-%04d-%04d", threadNamePrefix, id, threadID.getAndIncrement());
    Thread t = new Thread(runnable, threadName);
    t.setDaemon(isDaemon);
    //t.setUncaughtExceptionHandler(videoRecUncaughtExpHandler);
    // 匿名外部类实现未捕捉异样处理器
    t.setUncaughtExceptionHandler((thread, throwable) -> LOGGER.error("Thread:{} dead, error:{}", thread.getName(), throwable.getMessage()));
    return t;
}

线程池的应用

线程池的应用配置 ThreadPoolExecutorThreadPoolExecutor 的构造函数:

public ThreadPoolExecutor(
        int corePoolSize, // 线程池的根本大小,即指标大小
        int maximumPoolSize, // 线程池的最大大小
        long keepAliveTime, // 线程数量超过 coreSize 时,期待此存活工夫后依然闲暇则回收
        TimeUnit unit,
        BlockingQueue<Runnable> workQueue, // 工作队列
        ThreadFactory threadFactory, // 负责创立新线程
        RejectedExecutionHandler handler); // 当工作队列填满后执行的饱和策略

没有工作执行时线程池的大小是 coreSize;当工作队列满了,会创立超过 coreSize 的线程,但最大数量小于 maximumPoolSize。
超过 coreSize 的线程,在期待 keepAliveTime 之后依然闲暇则会被销毁,使线程数量回到 coreSize 大小。

设置线程池的大小

线程是低廉的资源,不能把线程池设置得过大,同时也要关注 JVM 过程总的线程数量,尽量减少利用的线程数量,进步线程的利用率。线程过多岂但占用更多内存,也减少了线程上下文切换的开销,导致 cpu 的利用十分低效。线程池大小的设置指标是:线程无闲暇,工作无期待。
线程池适合的大小(起源为《Java 并发编程实战》):

                Nthreads = Ncpu * Ucpu  * (1 + WaitTime / ComputeTime)   

公式解释 :cpu 的数量(Ncpu) 乘以 cpu 的指标利用率(Ucpu) 乘以(1 + 工作等待时间 与 其计算工夫的比值)。即工作等待时间越长(IO 密集型),线程池应该越大。相同,工作计算工夫越长(计算密集型),线程池应该越小。
举荐引擎服务中,大量的召回线程和排序线程都是在期待子服务返回,属于 IO 密集型。再联合设计的单机反对的 QPS 下限,设为 Q,每个工作(取用户画像,召回,排序)的均匀执行工夫为 T 毫秒,一次申请会触发的工作数量为 M,能够粗略估算服务须要的总线程数量为:

                 N = Q * M * T / 1000

QPS 有高下峰,能够应用日均 QPS 作为 Q 计算 N 的值作为 corePoolSize。高并发零碎须要的线程数量可能很宏大,再联合下面 Nthreads 的实践公式,估算单机须要调配多少 cpu。如果 cpu 过多,则须要调整单机的 QPS 设计下限。因为依据 Amdahl 定律,在减少计算资源的状况下,程序在实践上可能实现的最高减速比,取决于程序中可并行组件和串行组件的比重。并不是单机的 cpu 越多越好。

另外,能够在程序运行时 动静设置 corePoolSize 和 maximumPoolSize,ThreadPoolExecutor 提供了两个 public 办法:setCorePoolSize(int), setMaximumPoolSize(int)。
同时线程池也提供了办法在初始化时进行 线程池预热:prestartCoreThread(), prestartAllCoreThreads()。

通常,高并发零碎中须要进行 线程池隔离,避免所有工作提交到一个线程池产生饥饿景象。把重要工作提交到独立的隔离的线程池,从而保障重要工作的执行不受其余工作的影响。

QueueSize 设置

queueSize 设置的指标 是:高峰期能触发创立新的线程使线程数量裁减到 maximumPoolSize,其余时段线程数量维持在 corePoolSize,同时不会触发饱和策略
工作队列长度 queueSize 的设置次要依据:corePoolSize,单位工夫产生的工作数量,单个工作解决工夫,以及对工作等待时间的下限决定。如果 queueSize 设置过大,那么无奈使线程数量裁减到 maximumPoolSize;如果 queueSize 设置过小,会容易使队列满,从而频繁触发线程池饱和策略(默认是 AbortPolicy)。

比方,工作等待时间的下限为 100ms,100ms 会有 1500-4500(低谷和高峰期)个工作提交到线程池。单个工作的解决工夫为 3 -5ms,corePoolSize=90,那么 100ms 90 个线程能解决 90100/5 = 1800,或 3000 个工作。当线程数量裁减到 maximumPoolSize=150 时,100ms 的解决能力为 3000-5000 工作。那么 queueSize 设置在 4000 左右即可。

参考文献

[1] 周志明.《深刻了解 Java 虚拟机 - 第 3 版》机械工业出版社,2019
[2] Brian Goetz,Tim Peierls 等.《Java 并发编程实战》机械工业出版社,2012
[3] Eli Bendersky,上下文切换开销测量:https://eli.thegreenplace.net…,2018

正文完
 0