《java多线程编程实战指南》读书笔记 -- 基本概念

文章目录

  • 测试上下文切换工具
  • 减少上下文切换
  • 避免死锁
  • 创建线程成本
  • 线程状态
  • 获取线程转储(Thread dump)的方法
  • 竞态
  • 原子性
  • 可见性
  • 有序性
  • 上下文切换
    • 术语
    • 自发性上下文切换:
    • 非自发性上下文切换:
    • 开销及测量
  • 线程的活性故障
  • 资源争用与调度

并发:多个线程操作相同资源,保证线程安全,合理使用资源

高并发:服务能同时处理多个请求,提高程序性能

测试上下文切换工具

  • Lmbench3 测量上下文切换时长
  • vmstat 测量上下文切换次数

减少上下文切换

  • 无锁并发编程:将数据ID按hash算法取模分段,不同线程处理不同段数据。
  • CAS算法
  • 使用最少线程
  • 协程:在单线程中实现多任务调度并维持任务间切换

避免死锁

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁机制
  • 对数据库锁,加锁和解锁必须在一个数据库连接里

创建线程成本

java平台中,线程就是一个对象,创建需要分配内存。

与普通对象不同,jvm会为每个线程分配调用栈所需的内存空间,调用栈用于跟踪java方法间的调用关系以及java代码对Native Code(多为 C代码)的调用。

java中的每个线程可能还有一个内核线程(具体与jvm实现有关)与之对应。

线程状态

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jBnNfHDw-1576115827051)(evernotecid://626F545F-28E7-432D-B442-A76BAC946322/appyinxiangcom/14768996/ENResource/p91)]

获取线程转储(Thread dump)的方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4EOcSMZc-1576115827052)(evernotecid://626F545F-28E7-432D-B442-A76BAC946322/appyinxiangcom/14768996/ENResource/p92)]

eg:
mac中,使用jstack获取线程转储

//进入jdk安装地址
 » /usr/libexec/java_home -V  
 » cd /Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home 
 
//获取当前所有引用PID
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home » jps 

47697 Launcher
47698 OrtApp
46002 
46535 KotlinCompileDaemon
47818 Jps

//获取线程转储
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home » jstack -l 47698                                                                                                               
2019-09-23 09:24:48
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.181-b13 mixed mode):

"lettuce-kqueueEventLoop-9-1" #223 daemon prio=5 os_prio=31 tid=0x00007fe6cd166800 nid=0x16313 runnable [0x0000700018ed0000]
   java.lang.Thread.State: RUNNABLE
	at io.netty.channel.kqueue.Native.keventWait(Native Method)
	at io.netty.channel.kqueue.Native.keventWait(Native.java:94)
	at io.netty.channel.kqueue.KQueueEventLoop.kqueueWait(KQueueEventLoop.java:149)
	at io.netty.channel.kqueue.KQueueEventLoop.kqueueWait(KQueueEventLoop.java:140)
	at io.netty.channel.kqueue.KQueueEventLoop.run(KQueueEventLoop.java:216)
	at io.netty.util.concurrent.SingleThreadEventExecutor$5.run(SingleThreadEventExecutor.java:897)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
	- None
    
    ...

另外,在jdk安装路径下执行:

//打开jvisualvm
/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home » jvisualvm    

可打开图形化工具,其中可以获取线程的转储信息。
可用同样方式打开JMC。

竞态

竞态(Race Condition):线程间相互干扰,由脏读和更新丢失导致最终产生结果与预期结果不一致的现象。

计算的正确性依赖于相对时间顺序(Relative Time)或线程的交错(Interleaving)。

分析竞态的方法 - 二维表分析法

竞态产生条件:

  • 读-改-写(read-modify-write): 原因主要为读脏数据-覆盖其他线程对共享变量的更新
  • 检查而后行动(check-then-act): 原因主要为读取变量值,根据读取值决定下一步操作。读取值为脏数据,导致下一步操作出错。

原子性

原子性:

  • 对其他线程,执行线程的状态只有未开始和已完成两种,其他线程无法在执行线程访问(读、写)共享变量时,获取到其中间状态;
  • 访问同一组共享变量的原子操作是不能交错的。

Lock 软件锁;
CAS 硬件锁。

java基本数据类型中,long、double的写操作都不具有原子性。通过添加volatile关键字可以使其具有原子性。

可见性

可见性(Visibility): 程序中变量被分配到寄存器(Register)中处理,一个处理器的寄存器无法读取另一个处理器的寄存器。运行在不同处理器的线程共享变量分配到寄存器进行存储,就会出现可见性问题。

缓存一致性协议(Cache Coherence Protocol): 用于读取其他处理器高速缓存中数据,并更新到该处理器高速缓存中。

缓存同步:一个处理器从自身处理器缓存以外的其他存储部件中读取数据并将其更新到该处理器的高速缓存的过程。

高速缓存、主内存内容是可同步的

冲刷处理器缓存(写):为保障可见性,必须使一个处理器对共享变量做的更新最终被写入该处理器的高速缓存或主存中的过程。

刷新处理器缓存(读):若共享变量在处理器读取之前进行了更新,该处理器必须从进行更新操作的处理器的高速缓存或主存中将更新的内容进行缓存同步。

保障可见性,可以使用volatile

  • 提示JIT编译器,被修饰的变量可能被多个线程共享,阻止其做出可能导致程序运行不正常的优化;
  • 读取被修饰的变量会使相应处理器进行刷新处理器缓存处理,写被修饰变量会使相应处理器进行冲刷处理器缓存操作。

相对新值:一个线程更新共享变量后,其他线程能读到更新值,这个值称为变量的相对新值。
最新值:一个线程更新共享变量后,其他线程不能读到更新值,这个值称为变量的最新值。

可见性保障仅意味着线程能够读到共享变量的相对新值,并不能保证该线程能够读到最新值。
《java多线程编程实战指南》读书笔记 -- 基本概念_第1张图片
保障原子性,上述操作process2最终读取到的a值可能是0/1/2
保障可见性,上述操作process2最终读取到的a值为2

java规范保证,一个线程终止,其对共享变量的更新,另一个调用期join方法的线程是可见的

有序性

重排序(Reordering):一个处理器上执行多个操作,另一个处理器角度来看可能与目标代码所指定的顺序不一致。

几种内存操作顺序操作:

  • 源代码顺序:未经过编译和解释的源码中指定的内存访问操作顺序
  • 程序顺序:经过编译执行(机器码)或解释执行(字节码Byte Code)中指定的内存访问操作顺序
  • 执行顺序: 内存访问操作在给定处理器上实际执行顺序
  • 感知顺序:给定处理器锁感知到的该处理器及其他处理器的内存访问操作发生的顺序
    《java多线程编程实战指南》读书笔记 -- 基本概念_第2张图片

java平台包含两种编译器:

  • 静态编译器javac:将源代码(.java)编译成字节码(.class二进制文件), 代码编译阶段介入
  • 动态编译器JIT:将字节码动态编译为jaav虚拟机宿主机的本地代码(机器码), java程序运行过程中介入

javac几乎不会执行指令重排序;JIT可能执行指令重排序。

查看JIT编译器动态生成的汇编代码
下载hsdis反编译工具,将hsdis-amd64.dylib文件放到/Library/Java/JavaVirtualMachines/jdk1.8.0_101.jdk/Contents/Home/jre/lib/server下,和libjvm.dylib同级
文件放置好后,使用命令
java -server -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation -XX:PrintAssemblyOptions=intel [JAVA 文件路径]
即可看到指定代码的汇编代码内容.
-XX:LogFile=[xxx/xxx.log]可将反编译内容输出到指定文件中

现代处理器:“顺序读取” – “乱序执行” – “顺序提交” 也会导致重排序;
ROB(重排序缓冲器)

内存重排序
Load: 从指定RAM地址(通过高速缓存加载)加载数据到寄存器
Store: 将数据存储到指定地址表示的RAM存储单元

《java多线程编程实战指南》读书笔记 -- 基本概念_第3张图片

貌似串行语义(As-if-serial Semantics)
仅保证重排序不影响单线程程序的正确性。
为保证貌似串行语义,存在数据依赖关系的语句不会被重排序。若两个操作(指令)访问同一个变量(地址)且其中一个操作(指令)为写操作,那么两个操作之间就存在数据依赖关系。
《java多线程编程实战指南》读书笔记 -- 基本概念_第4张图片
控制依赖关系: 允许被重排序,若一条语句的执行结果会决定另外一条语句能够被执行,这两条语句存在控制依赖关系。如if语句中的条件表达式和对应的语句体。

从底层角度,禁止(逻辑上)重排序是通过调用处理器提供的相应指令(内存屏障)来实现的。java会替我们与这类指令打交道,我们只需要使用语言本身提供的机制即可。

上下文切换

术语

线程上下文切换: 一个线程被暂停,另一个线程被选中开始或继续运行的过程
时间片: 一个线程可以连续占用处理器运行的时间长度
切入: 一个线程被操作系统选中占用处理器开始或继续运行
切出: 一个线程被剥夺处理器使用权而暂停运行
上下文: 切入和切出时刻相应线程所执行的任务的进行程度(如计算中间结果、执行到哪条指令等),一般包含通用寄存器的内容和程序计数器的内容。
暂停: 线程由RUNNABLE状态转换为非RUNNABLE状态。
唤醒: 线程由非RUNNABLE状态转换为RUNNABLE状态。
被唤醒的线程并非立即占用处理器运行,当被唤醒的线程被操作系统选中占用处理器继续运行时,操作系统才会恢复其上下文。

自发性上下文切换:

由自身因素导致的切出,如下方法会导致自发性上下文切换
《java多线程编程实战指南》读书笔记 -- 基本概念_第5张图片
I/O操作或等待其他线程持有的锁

非自发性上下文切换:

由于线程调度器的原因被迫切出

  • 被切出线程时间片用完
  • 一个优先级更高的线程需要被运行
  • java虚拟机垃圾回收动作

开销及测量

直接开销:

  • 操作系统保存和恢复上下文所需开销
  • 线程调度器进行线程调度的开销

间接开销:

  • 处理器高速缓存重新加载的开销,切出线程被另一个处理器切入,继续运行,若新处理器从未运行过该线程,需要重新从主存货通过缓存一致性协议将线程运行过程中所需变量加载到高速缓存中
  • 可能导致整个一级缓存中的内容被冲刷(Flush),内容被写入下一级高速缓存或主存中

测量:
确定一个多线程程序在某个时间段或某种场景下运行时发生的上下文切换(主要是自发性上下文切换)的次数。

  • Linux平台下,使用其内核提供的perf命令来监视java程序运行过程中的上下文切换次数和频率。
    eg:perf stat -e cpu-clock, task-clock, cs, cache-references, cache-misses java [Main Class]
    其中参数e的值中,cs表示被监视程序的上下文切换的数量。
  • windows平台下,perform命令

线程的活性故障

由于资源稀缺性或程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或状态处于RUNNABLE状态但是其要执行的任务一直无法进展的现象就被称为线程活性故障

  • 死锁(Deadlock): 两个线程互相等待对方释放资源
  • 锁死(Lockout): 执行所需获取的资源一直未被释放
  • 活锁(Livelock): 线程处于RUNNABLE状态,但要执行的任务没有进展
  • 饥饿(Starvation): 因无法获得其所需资源而使得任务执行无法进展

资源争用与调度

排他性资源:一次只能够被一个线程占用的资源,如处理器、数据库连接、文件等。
资源争用:一个线程占用一个排他性资源进行读写操作而未释放其对资源所有权的时候,其他线程试图访问该资源的现象。
高/低争用:同时试图访问同一个已经被其他线程占用的资源的线程数量多/少。
高并发:处于运行状态(RUNNABLE的子状态RUNNING)的线程数量多,程序运行理想的状态为高并发、低争用。
资源调度:多个线程申请同一个排他性资源的情况下,决定哪个申请者占用该资源的过程。常见特性是它是否能保证公平性。
公平性:资源的申请者是否按照其申请资源的顺序而被授予资源的独占权。
排队:常见资源调度策略,调度器持有一个等待队列,未获取独占权的线程进入队列暂停,待资源释放被唤醒,从队列中移除,若再次申请资源失败,再次进入队列中暂停;由此可见,资源调度可能导致上下文切换。

公平调度策略:资源未被其他任何线程占用,等待队列为空的情况,资源的申请者才被允许抢占相应资源的独占权。抢占失败的申请者进入等待队列。此策略中资源申请者总是按照先来后到的顺序获得资源的独占权。
非公平调度策略:允许插队。一个线程释放其资源独占权的时候,等待队列中的一个线程会被唤醒再次申请相应的资源,而在这个过程中另外一个申请该线程的活跃线程可以与这个被唤醒的线程共同参与相应资源的抢占。可能导致饥饿现象。吞吐量更高,但申请者获取相应资源的独占权所需的时间偏差可能较大。

非公平调度策略 公平调度策略
适用于多数线程占用资源较短或资源平均申请时间间隔相对较短的场景 适用于多数线程占用资源较长或资源平均申请时间间隔相对较长的场景
吞吐率较大 吞吐率较小
可能导致饥饿状态 不会导致饥饿状态

等待队列中的线程从被唤醒到继续运行可能需要一段时间,此间新来线程若占用该资源时间不长,完全有可能在被唤醒线程继续执行之前将资源释放,此时可能减少了上下文切换次数。若新来线程占用资源时间太长,被唤醒资源需要重新被暂停进入等待队列中,增加了上下文切换次数。

你可能感兴趣的:(《java多线程编程实战指南》读书笔记 -- 基本概念)