目录
ReetrantLock vs synchronized
线程的状态
Java并发包的各种基础工具类包括
Executors 目前提供了 5 种不同的线程池创建配置
CAS是Java并发中所谓的lock-free机制的基础
类的加载分为:加载,链接,初始化
生成字节码方式包括
JVM内存区域划分
GC类型
GC调优
JMM
线程安全需要保证几个基本特征
synchronized使用 monitorenter/monitorexit
ReetrantLock
JDK1.6之前,Monitor对象同步是依靠操作系统内部的互斥锁实现的
需要用户态->内核态的切换,是一个重量级的操作
JVM对锁进行改进,包括三种不同的锁
轻量级锁会升级为重量级锁,同样重量级锁也会降级为轻量级
biasedLocking通过CAS设置Mark Word对象头,其结构如下
Java核心类库中的锁类型
NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAIT
TERMINATED
一个线程调用两次start(),第二次会抛异常
Java在Loom项目中,孕育新的类似轻量级用户线程Fiber等机制
影响线程的状态的因素
守护线程必须在启动之前设置
诡异的 Spurious wakeup问题
推荐使用下面写法
//推荐使用下面写法
while( isCondition() ) {
waitForAConfition(...);
}
// 不推荐,可能引入bug
if( isConfition() ) {
waitForAConfition(...);
}
Thread.onSpinWait() 是JDK9引入的特性,它没有任何行为上的保证,而是对JVM的一个暗示,JVM可能会利用CPU的pause指令
进一步提高性能,性能特别敏感的应用可以关注
ThradLocal如果清理value,其清理逻辑是在cleanSomeSlots和expungeStaleEntry中
ThreadLocal依赖于显示的触发,否则就要等待线程结束才能回收
区分线程状态 -> 查看等待目标 -> 对比Monitor等待有状态
通过jstack,ThreadMXBean可以检测死锁(MBean检测损耗性能)
死锁产生的原因
如果避免死锁
jstack检查到的死锁
各种工具类功能
CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;
而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
另外,CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。
并发包里面提供的线程安全Map,List,Set相关类图
TreeMap实现锁同步很困难,于是就用SkipListMap实现了
java.util.concurrent包提供的容器(Queue,List,Set),Map 从命名上大致区分为Concurrent*, CopyOnWrite
和Blocking等三类,他们都是线程安全的
并发包容器的类图
容器之间的细节差别
各个类型的主要设计目的
下图是 应用与线程池的交互,以及线程池的内部工作过程
上图的具体含义
线程池的几个参数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler h,andler)
下图是线程池状态流转图,对线程池的可能状态和其内部方法之间进行了对应
线程池实践
线程池大小选择策略
x86CPU是用cmpxchg指定实现的
而RISC的CPU使用load and reserve和store conditional 一对指定实现的
Java9之后提供了Variable Handle API,源自于JEP 193,提供各种粒度的原子或有序性的操作
将AtomicLong替换为LongAddr,在高度竞争环境下性能更好,本质是空间换时间
在高度竞争下,要限制自旋的次数
lock-free的ABA问题,提供了AtomicStampedReference,通过颁布来解决
Doug Lea曾经介绍过AQS的设计初衷,从原理上,一种同步结构往往是可以利用其它的结构实现的
AQS内部数据和函数可以拆分如下
利用AQS实现一个同步结构,至少要实现两个基本类型的函数,分别是acquire操作获取资源的独占权
还有release操作,释放对某个资源的独占
tryAcquire()包括了公平和非公平抢占模式
类加载器的层次
# 指定新的 bootclasspath,替换 java.* 包的内部实现
java -Xbootclasspath: your_App
# a 意味着 append,将指定目录添加到 bootclasspath 后面
java -Xbootclasspath/a: your_App
# p 意味着 prepend,将指定目录添加到 bootclasspath 前面
java -Xbootclasspath/p: your_App
java -Djava.ext.dirs=your_ext_dir HelloWorld
java -Djava.system.class.loader=com.yourcorp.YourClassLoader HelloWorld
在 JDK 9 中,由于 Jigsaw 项目引入了 Java 平台模块化系统(JPMS),Java SE 的源代码被划分为一系列模块。
类加载器,类文件容器等都发生了非常大的变化,
java --patch-module java.base=your_patch yourApp
结合了 Layer,目前的 JVM 内部结构就变成了下面的层次,内建类加载器都在 BootLayer 中,其他 Layer 内部有自定义的类加载器,不同版本模块可以同时工作在不同的Layer
如何提高类的加载速度版本
AOT,相当于直接编译成机器码,降低的其实主要是解释和编译开销。但是其目前还是个试验特性,支持的平台也有限,比如,JDK 9 仅支持 Linux x64,所以局限性太大
还有就是较少人知道的 AppCDS(Application Class-Data Sharing),CDS 在 Java 5 中被引进,但仅限于 Bootstrap Class-loader,在 8u40 中实现了 AppCDS,支持其他的类加载器,在目前 2018 年初发布的 JDK 10 中已经开源。
AppCDS 基本原理和工作过程是:
首先,JVM 将类信息加载, 解析成为元数据,并根据是否需要修改,将其分类为 Read-Only 部分和 Read-Write 部分。然后,将这些元数据直接存储在文件系统中,作为所谓的 Shared Archive。命令很简单:
Java -Xshare:dump -XX:+UseAppCDS -XX:SharedArchiveFile= \
-XX:SharedClassListFile= -XX:SharedArchiveConfigFile=
第二,在应用程序启动时,指定归档文件,并开启 AppCDS。
Java -Xshare:on -XX:+UseAppCDS -XX:SharedArchiveFile= yourApp
通过上面的命令,JVM 会通过内存映射技术,直接映射到相应的地址空间,免除了类加载、解析等各种开销。
AppCDS 改善启动速度非常明显,传统的 Java EE 应用,一般可以提高 20%~30% 以上;实验中使用 Spark KMeans 负载,20 个 slave,可以提高 11% 的启动速度。
与此同时,降低内存 footprint,因为同一环境的 Java 进程间可以共享部分数据结构。前面谈到的两个实验,平均可以减少 10% 以上的内存消耗。
当然,也不是没有局限性,如果恰好大量使用了运行时动态类加载,它的帮助就有限了
一个普通的Java动态代理,其实现可以简化为
动态生成类用在下面领域
内存结构图如下
OutOfMemoryError类型
内存划分
老年代,from-to,Eden,持久带
每个线程都有一个私有缓存区域TLB
JVM堆空间分布情况
java -XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics HelloWorld
hello!
Native Memory Tracking:
Total: reserved=1572491KB, committed=35187KB
- Java Heap (reserved=253952KB, committed=16384KB)
(mmap: reserved=253952KB, committed=16384KB)
- Class (reserved=1056877KB, committed=4973KB)
(classes #391)
(malloc=109KB #124)
(mmap: reserved=1056768KB, committed=4864KB)
- Thread (reserved=9272KB, committed=9272KB)
(thread #9)
(stack: reserved=9232KB, committed=9232KB)
(malloc=29KB #47)
(arena=11KB #18)
- Code (reserved=249630KB, committed=2566KB)
(malloc=30KB #293)
(mmap: reserved=249600KB, committed=2536KB)
- GC (reserved=839KB, committed=71KB)
(malloc=7KB #79)
(mmap: reserved=832KB, committed=64KB)
- Compiler (reserved=132KB, committed=132KB)
(malloc=1KB #22)
(arena=131KB #3)
- Internal (reserved=197KB, committed=197KB)
(malloc=165KB #1191)
(mmap: reserved=32KB, committed=32KB)
- Symbol (reserved=1356KB, committed=1356KB)
(malloc=900KB #66)
(arena=456KB #1)
- Native Memory Tracking (reserved=32KB, committed=32KB)
(malloc=2KB #28)
(tracking overhead=30KB)
- Arena Chunk (reserved=204KB, committed=204KB)
(malloc=204KB)
第一部分非常明显是 Java 堆,我已经分析过使用什么参数调整,不再赘述。
第二部分是 Class 内存占用,它所统计的就是 Java 类元数据所占用的空间,JVM 可以通过类似下面的参数调整其大小:
-XX:MaxMetaspaceSize=value
对于本例,因为 HelloWorld 没有什么用户类库,所以其内存占用主要是启动类加载器(Bootstrap)加载的核心类库。你可以使用下面的小技巧,调整启动类加载器元数据区,这主要是为了对比以加深理解,也许只有在 hack JDK 时才有实际意义。
-XX:InitialBootClassLoaderMetaspaceSize=30720
下面是 Thread,这里既包括 Java 线程,如程序主线程、Cleaner 线程等,也包括 GC 等本地线程。你有没有注意到,即使是一个 HelloWorld 程序,这个线程数量竟然还很多,似乎有很多浪费,设想我们要用 Java 作为 Serverless 运行时,每个 function 是非常短暂的
可以关闭分层编译,内存消耗也会降低
-XX:-TieredCompilation
接下来是 Code 统计信息,显然这是 CodeCache 相关内存,也就是 JIT compiler 存储编译热点方法等信息的地方,JVM 提供了一系列参数可以限制其初始值和最大值等,例如:
-XX:InitialCodeCacheSize=value
-XX:ReservedCodeCacheSize=value
可以设置下列 JVM 参数,也可以只设置其中一个,进一步判断不同参数对 CodeCache 大小的影响。
很明显,CodeCache 空间下降非常大,这是因为我们关闭了复杂的 TieredCompilation,而且还限制了其初始大小。
下面就是 GC 部分了,G1 等垃圾收集器其本身的设施和数据结构就非常复杂和庞大,例如 Remembered Set 通常都会占用 20%~30% 的堆空间。如果我把 GC 明确修改为相对简单的 Serial GC,
使用命令:
-XX:+UseSerialGC
不仅总线程数大大降低,而且 GC 设施本身的内存开销就少了非常多,AWS Lambda 中 Java 运行时就是使用的 Serial GC,可以大大降低单个 function 的启动和运行开销。
Compiler 部分,就是 JIT 的开销,显然关闭 TieredCompilation 会降低内存使用。
其他一些部分占比都非常低,通常也不会出现内存使用问题。唯一的例外就是 Internal(JDK 11 以后在 Other 部分)部分,其统计信息包含着 Direct Buffer 的直接内存,这其实是堆外内存中比较敏感的部分,很多堆外内存 OOM 就发生在这里。
原则上 Direct Buffer 是不推荐频繁创建或销毁的,如果怀疑直接内存区域有问题,通常可以通过类似 instrument 构造函数等手段,排查可能的问题。
Serial GC
ParNewGC
ParrallelGC
CMS
G1
垃圾收集算法
垃圾收集算法
GC发展趋势
内存占用 footprint
延迟 latency
吞吐量 throughput
G1 GC的内部结构和主要机制
region个数是2048个左右,包括Eden,Survivor,Old region,还将超过region大小50%的对象归为Humongous对象,并放到相应的region中,这个区算是老年代的一部分
region设计的副作用
region大小和大对象很难保证一致,这会导致空间的浪费
特别大的对象可能占用超过一个region的,region太小不合适,导致在分配大对象时更难找到连续空间,这是长久存在的情况,本质可以看到是JVM的bug,解决办法是设置较大的region大小
-XX:G1HeapRegionSize=N
GC算法
在新生代,G1采用的仍是并行复制算法,同样会发生Stop-The-World暂停
在老年代,大部分情况下都是并发标记,而整理Compact则是和新生代GC时捎带进行,并且不是整体性的整理,而是增量进行的
对G1来说
Minor GC仍然存在,虽然具体过程会有区别,会涉及Remembered Set等相关处理
老年代回收,则是依靠Mixed GC,并发标记结束后,JVM就有足够的信息进行垃圾收集,Mixed GC不仅同时会清理Eden,Survivor区域,而且还会清理部分Old区域,可以通过设置下面的参数,指定触发阈值,并且设定最多被包含在一次Mixed GC中的region比例
-XX:G1MixedGCLiveThresholdPercent
-XX:G1OldCSetRegionThresholdPercent
下图是G1内部的运行角度,正常运行的状态流转变化
G1 相关概念非常多,有一个重点就是 Remembered Set,用于记录和维护 region 之间对象的引用关系。为什么需要这么做呢?试想,新生代 GC 是复制算法,也就是说,类似对象从 Eden 或者 Survivor 到 to 区域的“移动”,其实是“复制”,本质上是一个新的对象。在这个过程中,需要必须保证老年代到新生代的跨区引用仍然有效。
G1 的很多开销都是源自 Remembered Set,例如,它通常约占用 Heap 大小的 20% 或更高,这可是非常可观的比例。并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。
Humongous 对象的分配和回收,这是很多内存问题的来源,Humongous region 作为老年代的一部分,通常认为它会在并发标记结束后才进行回收,但是在新版 G1 中,Humongous 对象回收采取了更加激进的策略。
G1 记录了老年代 region 间对象引用,Humongous 对象数量有限,所以能够快速的知道是否有老年代对象引用它。如果没有,能够阻止它被回收的唯一可能,就是新生代是否有对象引用了它,但这个信息是可以在 Young GC 时就知道的,所以完全可以在 Young GC 中就进行 Humongous 对象的回收,不用像其他老年代对象那样,等待并发标记结束。
在 8u20 以后字符串排重的特性,在垃圾收集过程中,G1 会把新创建的字符串对象放入队列中,然后在 Young GC 之后,并发地(不会 STW)将内部数据(char 数组,JDK 9 以后是 byte 数组)一致的字符串进行排重,也就是将其引用同一个数组。你可以使用下面参数激活:
-XX:+UseStringDeduplication
注意,这种排重虽然可以节省不少内存空间,但这种并发操作会占用一些 CPU 资源,也会导致 Young GC 稍微变慢。
类型卸载是个长期困扰一些 Java 应用的问题,一个类只有当加载它的自定义类加载器被回收后,才能被卸载。元数据区替换了永久代之后有所改善,但还是可能出现问题。
可以加上下面的参数查看类型卸载:
-XX:+TraceClassUnloading
8u40 以后,G1 增加并默认开启下面的选项,在并发标记阶段结束后,JVM 即进行类型卸载。
-XX:+ClassUnloadingWithConcurrentMark
老年代对象回收,基本要等待并发标记结束。这意味着,如果并发标记结束不及时,导致堆已满,但老年代空间还没完成回收,就会触发 Full GC,所以触发并发标记的时机很重要。早期的 G1 调优中,通常会设置下面参数,但是很难给出一个普适的数值,往往要根据实际运行结果调整
-XX:InitiatingHeapOccupancyPercent
在 JDK 9 之后的 G1 实现中,这种调整需求会少很多,因为 JVM 只会将该参数作为初始值,会在运行时进行采样,获取统计数据,然后据此动态调整并发标记启动时机。对应的 JVM 参数如下,默认已经开启:
-XX:+G1UseAdaptiveIHOP
下面从整体上给出一些调优的建议。
首先,建议尽量升级到较新的 JDK 版本,从上面介绍的改进就可以看到,很多人们常常讨论的问题,其实升级 JDK 就可以解决了。
第二,掌握 GC 调优信息收集途径。掌握尽量全面、详细、准确的信息,是各种调优的基础,不仅仅是 GC 调优。我们来看看打开 GC 日志
//常用的两个选项,
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
//打印 G1 Ergonomics 相关信息
-XX:+PrintAdaptiveSizePolicy // 打印 G1 Ergonomics 相关信息
//GC 内部一些行为是适应性的触发的,利用 PrintAdaptiveSizePolicy,我们就可以知道为什么 JVM 做出//了一些可能我们不希望发生的动作。例如,G1 调优的一个基本建议就是避免进行大量的 Humongous 对象//分配,如果 Ergonomics 信息说明发生了这一点,那么就可以考虑要么增大堆的大小,要么直接将 //region 大小提高。
//如果是怀疑出现引用清理不及时的情况,则可以打开下面选项,掌握到底是哪里出现了堆积。
-XX:+PrintReferenceGC
//开启选项下面的选项进行并行引用处理。
-XX:+ParallelRefProcEnabled
//JDK 9 中 JVM 和 GC 日志机构进行了重构,其实我前面提到的PrintGCDetails 已经被标记为
//而PrintGCDateStamps 已经被移除,指定它会导致 JVM 无法启动。可以使用下面的命令查询新的配置//参数。
java -Xlog:help
最后来看一些通用实践
//如果发现 Young GC 非常耗时,这很可能就是因为新生代太大了,我们可以考虑减小新生代的最小比例。
-XX:G1NewSizePercent
//降低其最大值同样对降低 Young GC 延迟有帮助
//如果我们直接为 G1 设置较小的延迟目标值,也会起到减小新生代的效果,虽然会影响吞吐量
-XX:G1MaxNewSizePercent
//如果是 Mixed GC 延迟较长,部分 Old region 会被包含进 Mixed GC,减少一次处理的 region 个
//数,就是个直接的选择之一。
// G1OldCSetRegionThresholdPercent 控制其最大值,还可以利用下面参数提高 Mixed GC 的个数,
//当前默认值是 8,Mixed GC 数量增多,意味着每次被包含的 region 减少
-XX:G1MixedGCCountTarget
具体表现形式包括但远不止是我们直觉中的 synchronized、volatile、lock 操作顺序等方面
JMM之前的状况
所以,Java 迫切需要一个完善的 JMM,能够让普通 Java 开发者和编译器、JVM 工程师,能够清晰地达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。
对于编译器、JVM 开发者,关注点可能是如何使用类似内存屏障(Memory-Barrier)之类技术,保证执行结果符合 JMM 的推断。
对于 Java 应用开发者,则可能更加关注 volatile、synchronized 等语义,如何利用类似 happen-before 的规则,写出可靠的多线程应用,而不是利用一些“秘籍”去糊弄编译器、JVM。
JVM 内部的运行时数据区,但是真正程序执行,实际是要跑在具体的处理器内核上。你可以简单理解为,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。可以从下图中看到两种模型的对应
JMM 内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种 happen-before 规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。
对于一个 volatile 变量:
内存屏障能够在类似变量读、写操作之后,保证其他线程对 volatile 变量的修改对当前线程可见,或者本地修改对其他线程提供可见性。换句话说,线程写入,写屏障会通过类似强迫刷出处理器缓存的方式,让其他线程能够拿到最新数值。