Java核心技术36讲-进阶部分

目录

ReetrantLock vs synchronized

线程的状态

Java并发包的各种基础工具类包括

Executors 目前提供了 5 种不同的线程池创建配置

CAS是Java并发中所谓的lock-free机制的基础

类的加载分为:加载,链接,初始化

生成字节码方式包括

JVM内存区域划分

GC类型

GC调优

JMM


ReetrantLock vs synchronized

线程安全需要保证几个基本特征

  • 原子性,相关操作不会中途被其他线程干扰,一般通过同步机制实现
  • 可见性,一个线程修改了某个共享变量,其状态能立即被其他线程知晓
  • 有序性,是保证线程内串行语义,避免指定重拍

synchronized使用 monitorenter/monitorexit

ReetrantLock

  • 支持公平性,会品倾向于把锁赋给等待时间最久的线程,synchronized是不公平的
  • 一般情况下公平性没有那么重要,Java默认调度策略很少会导致饥饿,除非确实有公平性需要才要指定
  • 带超时的获取锁尝试
  • 可以判断是否有线程,或者某个特定线程,在排队等待获取锁
  • 可以响应中断请求
  • ReentrantLock还包括了条件变量(java.util.concurrent.Condition)
  • 不要将获取锁的过程写在try块内,因为如果在获取锁时发生了异常,异常抛出的同时,也会导致锁无故被释放
     


JDK1.6之前,Monitor对象同步是依靠操作系统内部的互斥锁实现的
需要用户态->内核态的切换,是一个重量级的操作
JVM对锁进行改进,包括三种不同的锁

  1. 偏向锁 Biased Locking,线程内部不涉及互斥操作
  2. 轻量级锁,通过CAS方式实现的
  3. 重量级锁

轻量级锁会升级为重量级锁,同样重量级锁也会降级为轻量级
biasedLocking通过CAS设置Mark Word对象头,其结构如下

Java核心技术36讲-进阶部分_第1张图片

Java核心类库中的锁类型

Java核心技术36讲-进阶部分_第2张图片

 

 

线程的状态

NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAIT
TERMINATED

一个线程调用两次start(),第二次会抛异常
Java在Loom项目中,孕育新的类似轻量级用户线程Fiber等机制

影响线程的状态的因素

  • 线程自身的方法,除了 start,还有多个 join 方法,等待线程结束;yield 是告诉调度器,主动让出 CPU;另外,就是一些已经被标记为过时的 resume、stop、suspend 之类,
  • 在 JDK 最新版本中,destory/stop 方法将被直接移除。基类 Object 提供了一些基础的 wait/notify/notifyAll 方法。如果我们持有某个对象的 Monitor 锁,调用 wait 会让当前线程处于等待状态,直到其他线程 notify 或者 notifyAll。所以,本质上是提供了 Monitor 的获取和释放的能力,是基本的线程间通信方式。
  • 并发类库中的工具,比如 CountDownLatch.await() 会让当前线程进入等待状态,直到 latch 被基数为 0,这可以看作是线程间通信的 Signal。

Java核心技术36讲-进阶部分_第3张图片


守护线程必须在启动之前设置
诡异的 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检测损耗性能)

死锁产生的原因

  • 互斥条件,类似Java中Monitor都是独占的
  • 互斥条件长期持有
  • 循环依赖关系
  • 类加载过程中发生的死锁,尤其是框架大量使用自定义类时加载出现的

如果避免死锁

  • 尽量避免使用多个锁,NIO中就使用了好几个锁,出现了死锁情况
  • 参考银行家算法,设计好获取锁的顺序
  • 带超时的锁
  • 通过FindBugs静态检查代码发现死锁

jstack检查到的死锁

Java核心技术36讲-进阶部分_第4张图片

 

 

Java并发包的各种基础工具类包括

  • 提供了比synchronized更加高级的各种同步结构,包括CountDownLatch,CyclicBarrier,Semaphore等,可以实现更加丰富的多线程操作,如利用Semaphore作为资源控制器,限制同时进行工作的线程数量
  • 各种线程安全的容器,如ConcurrentHashMap,有序的ConcurrentSkipListMap,或通过类似快照机制,实现线程安全的动态数组CopyOnWriteArrayList等
  • 各种并发队列实现,如各种BlockedQueue实现,典型的ArrayBlockingQueue,SynchorousQueue或针对特定场景的PriorityBlockingQueue等
  • Executor框架,可以创建各种不同类型的线程池,调度任务运行等,绝大部分情况下,不再需要自己从头实现线程池和任务调度器


各种工具类功能

  • CountDownLatch,允许一个或多个线程等待某些操作完成
  • CyclicBarrier,一种辅助性的同步结构,允许多个线程等待到达某个屏障
  • Semaphore,Java版本的信号量实现

CountDownLatch一般用于某个线程A等待若干个其他线程执行完任务之后,它才执行;
而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
另外,CountDownLatch是不能够重用的,而CyclicBarrier是可以重用的。

 

并发包里面提供的线程安全Map,List,Set相关类图

Java核心技术36讲-进阶部分_第5张图片

TreeMap实现锁同步很困难,于是就用SkipListMap实现了

Java核心技术36讲-进阶部分_第6张图片

 

java.util.concurrent包提供的容器(Queue,List,Set),Map 从命名上大致区分为Concurrent*, CopyOnWrite
和Blocking等三类,他们都是线程安全的

  1. Concurrent类型没有类似CopyOnWrite之类容器相对较重的修改开销
  2. 但Concurrent提供了较低的遍历一致性,当利用迭代器遍历时,如果容器发生修改,迭代器仍可以继续遍历
  3. 与若已知对应的是同步容器的fail-fast,当检测到容器在遍历过程中发生了修改,则抛错异常
  4. 弱一致性的另一个体现是,size等操作的准确性是有限的,未必100%准确
  5. 与此同时,读取性能具有一定的不确定性

并发包容器的类图

Java核心技术36讲-进阶部分_第7张图片

容器之间的细节差别

  1. ArrayBlockingQueue是有界队列
  2. LinkedBlockingQueue实际是有界的,但若不指定就变成无界了
  3. SynchronousQueue,每个删除都要等待插入,这个队列容量是0
  4. PriorityBlockingQueue是无边界有限队列
  5. DelayedQueue和LinkedTransferQueue,同样是无边界队列
  6. 从空间利用角度,ArrayBlockingQueue要比LinkedBlockingQueue紧凑
  7. 通用场景LinkedBlockingQueue的吞吐量一般优于ArrayBlockingQueue,因为锁粒度更细
  8. ArrayBlocingQueue实现简单性能更好预测,表现稳定
  9. 如果要在两个线程之间接力性场景,SynchronousQueue是最完美负荷的
  10. 队列元素较小时SynchronousQueue比其他队列性能都好
  11. Concurrent类基于lock-free,在多线程场景可以提高吞吐量
  12. LinkedBlocingQueue内部基于锁

 

Executors 目前提供了 5 种不同的线程池创建配置

  1. newCachedThreadPool(),它是一种用来处理大量短时间工作任务的线程池,它会试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程;如果线程闲置的时间超过 60 秒,则被终止并移出缓存;长时间闲置时,这种线程池,不会消耗什么资源。其内部使用 SynchronousQueue 作为工作队列
  2. newFixedThreadPool(int nThreads),重用指定数目(nThreads)的线程,其背后使用的是无界的工作队列,任何时候最多有 nThreads 个工作线程是活动的。如果任务数量超过了活动队列数目,将在工作队列中等待空闲线程出现;如果有工作线程退出,将会有新的工作线程被创建,以补足指定的数目 nThreads。
  3. newSingleThreadExecutor(),工作线程数目被限制为 1,操作一个无界的工作队列,所以它保证了所有任务的都是被顺序执行,最多会有一个任务处于活动状态,并且不允许使用者改动线程池实例,因此可以避免其改变线程数目。
  4. newSingleThreadScheduledExecutor() 和 newScheduledThreadPool(int corePoolSize),创建的是个ScheduledExecutorService,可以进行定时或周期性的工作调度,区别在于单一工作线程还是多个工作线程
  5. newWorkStealingPool(int parallelism),这是一个经常被人忽略的线程池,Java 8 才加入这个创建方法,其内部会构建ForkJoinPool,利用Work-Stealing算法,并行地处理任务,不保证处理顺序。

 

Executor框架的基本组成
Java核心技术36讲-进阶部分_第8张图片

各个类型的主要设计目的

  1. Executor是一个基础的接口,其初衷是将任务提交和任务执行细节解耦
  2. EexcutorSerivce,更加完善,不仅提供serice的管理功能如shutdown等,还提供更全的任务提交机制,如返回Future而不是void
  3. Java标准类库提供几种基础实现,如ThreadPoolExecutor,ScheduleThreadPoolExecutor,ForkJoinPool,其特点在于高度的可调节性和灵活性,以满足复杂多变的实际应用场景
  4. Executors则从简化使用的角度,提供了各种方便的静态工程函数

下图是 应用与线程池的交互,以及线程池的内部工作过程

Java核心技术36讲-进阶部分_第9张图片

上图的具体含义

  • 工作队列负责存储用户提交的各个任务,这个工作队列,可以是容量为 0 的 SynchronousQueue(使用newCachedThreadPool),也可以是像固定大小线程池(newFixedThreadPool)那样使用 LinkedBlockingQueue。
  • 内部的“线程池”,这是指保持工作线程的集合,线程池需要在运行过程中管理线程创建、销毁。例如,对于带缓存的线程池,当任务压力较大时,线程池会创建新的工作线程;当业务压力退去,线程池会在闲置一段时间(默认 60 秒)后结束线程。线程池的工作线程被抽象为静态内部类 Worker,基于AQS实现。
  • ThreadFactory 提供上面所需要的创建线程逻辑。
  • 如果任务提交时被拒绝,比如线程池已经处于 SHUTDOWN 状态,需要为其提供处理逻辑,Java 标准库提供了类似ThreadPoolExecutor.AbortPolicy等默认实现,也可以按照实际需求自定义。

线程池的几个参数

  • corePoolSize,所谓的核心线程数,可以大致理解为长期驻留的线程数目(除非设置了 allowCoreThreadTimeOut)。对于不同的线程池,这个值可能会有很大区别,比如 newFixedThreadPool 会将其设置为 nThreads,而对于newCachedThreadPool 则是为 0。
  • maximumPoolSize,顾名思义,就是线程不够时能够创建的最大线程数。同样进行对比,对于newFixedThreadPool,当然就是 nThreads,因为其要求是固定大小,而 newCachedThreadPool 则是Integer.MAX_VALUE。
  • keepAliveTime 和 TimeUnit,这两个参数指定了额外的线程能够闲置多久,显然有些线程池不需要它。
  • workQueue,工作队列,必须是 BlockingQueue。
public ThreadPoolExecutor(int corePoolSize,
                      int maximumPoolSize,
                      long keepAliveTime,
                      TimeUnit unit,
                      BlockingQueue workQueue,
                      ThreadFactory threadFactory,
                      RejectedExecutionHandler h,andler)

下图是线程池状态流转图,对线程池的可能状态和其内部方法之间进行了对应

Java核心技术36讲-进阶部分_第10张图片


线程池实践

  • 避免任务堆积。前面我说过 newFixedThreadPool 是创建指定数目的线程,但是其工作队列是无界的,如果工作线程数目太少,导致处理跟不上入队的速度,这就很有可能占用大量系统内存,甚至是出现 OOM。诊断时,你可以使用 jmap 之类的工具,查看是否有大量的任务对象入队。
  • 避免过度扩展线程。我们通常在处理大量短时任务时,使用缓存的线程池,比如在最新的 HTTP/2 client API 中,目前的默认实现就是如此。我们在创建线程池的时候,并不能准确预计任务压力有多大、数据特征是什么样子(大部分请求是 1K 、100K 还是 1M 以上?),所以很难明确设定一个线程数目。
  • 如果线程数目不断增长(可以使用 jstack 等工具检查),也需要警惕另外一种可能性,就是线程泄漏,这种情况往往是因为任务逻辑有问题,导致工作线程迟迟不能被释放。建议你排查下线程栈,很有可能多个线程都是卡在近似的代码处。
  • 避免死锁等同步问题
  • 尽量避免在使用线程池时操作 ThreadLocal


线程池大小选择策略

  • 通常建议按照 CPU 核的数目 N 或者 N+1。
  • 如果是需要较多等待的任务,例如 I/O 操作比较多,可以参考 Brain Goetz 推荐的计算方法
  • 线程数 = CPU 核数 × 目标 CPU 利用率 ×(1 + 平均等待时间 / 平均工作时间)
  • 这些时间并不能精准预计,需要根据采样或者概要分析等方式进行计算,然后在实际中验证和调整。
  • 上面是仅仅考虑了 CPU 等限制,实际还可能受各种系统资源限制影响,例如我最近就在 Mac OS X 上遇到了大负载时ephemeral 端口受限的情况。当然,我是通过扩大可用端口范围解决的,如果我们不能调整资源的容量,那么就只能限制工作线程的数目了。这里的资源可以是文件句柄、内存等。
  • 另外,在实际工作中,不要把解决问题的思路全部指望到调整线程池上,很多时候架构上的改变更能解决问题,比如利用背压机制的Reactive Stream、合理的拆分等。

 


CAS是Java并发中所谓的lock-free机制的基础

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内部数据和函数可以拆分如下

  • 一个volatile的整数成员表征状态,同时提供了setState和getState函数
  • 一个先入先出FIFO的等待线程队列,以实现多线程间竞争和等待,这是AQS机制的核心之一
  • 各种机遇CAS的基础操作方法以及各种期望具体同步结构去实现的acquire/release函数

利用AQS实现一个同步结构,至少要实现两个基本类型的函数,分别是acquire操作获取资源的独占权
还有release操作,释放对某个资源的独占
tryAcquire()包括了公平和非公平抢占模式

 

 


类的加载分为:加载,链接,初始化

  • 加载阶段是把jar或者class加载到内如中
  • 第二阶段是链接(Linking),把原始的类定义信息平滑地转化入 JVM 运行的过程中。这里可进一步细分为三个步骤:
  • 验证(Verification),这是虚拟机安全的重要保障,JVM 需要核验字节信息是符合 Java 虚拟机规范的,否则就被认为是 VerifyError,这样就防止了恶意信息或者不合规的信息危害 JVM 的运行,验证阶段有可能触发更多 class 的加载
  • 准备(Preparation),创建类或接口中的静态变量,并初始化静态变量的初始值。但这里的“初始化”和下面的显式初始化阶段是有区别的,侧重点在于分配所需要的内存空间,不会去执行更进一步的 JVM 指令
  • 解析(Resolution),在这一步会将常量池中的符号引用(symbolic reference)替换为直接引用。在Java 虚拟机规范中,详细介绍了类、接口、方法和字段等各个方面的解析。
  • 最后是初始化阶段(initialization),这一步真正去执行类初始化的代码逻辑,包括静态字段赋值的动作,以及执行类定义中的静态初始化块内的逻辑,编译器在编译阶段就会把这部分逻辑整理好,父类型的初始化逻辑优先于当前类型的逻辑


类加载器的层次

  • 启动类加载器 Bootstrap ClassLoader
# 指定新的 bootclasspath,替换 java.* 包的内部实现
java -Xbootclasspath: your_App

# a 意味着 append,将指定目录添加到 bootclasspath 后面
java -Xbootclasspath/a: your_App


# p 意味着 prepend,将指定目录添加到 bootclasspath 前面
java -Xbootclasspath/p: your_App
  • 扩展类加载器 Extension or Ext Class-Loader,负责加载/jre/lib/ext目录下的jar包
java -Djava.ext.dirs=your_ext_dir HelloWorld
  • 应用类加载器 Application or App Class-Loader
java -Djava.system.class.loader=com.yourcorp.YourClassLoader HelloWorld

在 JDK 9 中,由于 Jigsaw 项目引入了 Java 平台模块化系统(JPMS),Java SE 的源代码被划分为一系列模块。
类加载器,类文件容器等都发生了非常大的变化,

  • -Xbootclasspath 参数不可用了。API 已经被划分到具体的模块,所以上文中,利用“-Xbootclasspath/p”替换某个 Java 核心类型代码,实际上变成了对相应的模块进行的修补,可以采用下面的解决方案:首先,确认要修改的类文件已经编译好,并按照对应模块(假设是 java.base)结构存放, 然后,给模块打补丁:
java --patch-module java.base=your_patch yourApp
  • 扩展类加载器被重命名为平台类加载器(Platform Class-Loader),而且 extension 机制则被移除。也就意味着,如果我们指定 java.ext.dirs 环境变量,或者 lib/ext 目录存在,JVM 将直接返回错误!建议解决办法就是将其放入 classpath 里
  • 部分不需要 AllPermission 的 Java 基础模块,被降级到平台类加载器中,相应的权限也被更精细粒度地限制起来。
  • rt.jar 和 tools.jar 同样是被移除了!JDK 的核心类库以及相关资源,被存储在 jimage 文件中,并通过新的 JRT 文件系统访问,而不是原有的 JAR 文件系统。虽然看起来很惊人,但幸好对于大部分软件的兼容性影响,其实是有限的,更直接地影响是 IDE 等软件,通常只要升级到新版本就可以了
  • 增加了 Layer 的抽象, JVM 启动默认创建 BootLayer,开发者也可以自己去定义和实例化 Layer,可以更加方便的实现类似容器一般的逻辑抽象。

结合了 Layer,目前的 JVM 内部结构就变成了下面的层次,内建类加载器都在 BootLayer 中,其他 Layer 内部有自定义的类加载器,不同版本模块可以同时工作在不同的Layer

Java核心技术36讲-进阶部分_第11张图片

如何提高类的加载速度版本
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% 以上的内存消耗。
当然,也不是没有局限性,如果恰好大量使用了运行时动态类加载,它的帮助就有限了

 

 

生成字节码方式包括

  • 启动一个ProcessBuilder的javac进程,编译类再加载
  • 使用java.compiler编译类
  • 使用ASM,javassist,cglib动态生成字节码
  • Java的动态代理,dynamic proxy也是用字节码操纵的方式实现的
  • java.lang.instrumentation API,Lambda字节码都是用ASM实现的
  • 更高层次视角的类库可以用 Byte Buddy

一个普通的Java动态代理,其实现可以简化为

  • 提供一个基础接口,作为被调用类和代理类之间的统一入口
  • 实现InvocationHandler,对代理对象的函数调用,会被分派到期invoke函数来真正实现动作
  • 通过Proxy类,调用其newProxyInstance函数,生产一个实现了相应基础接口的代理类实例

动态生成类用在下面领域

  • 各种Mock框架
  • ORM框架
  • IOC容器
  • 部分Profiler工具,或者运行时诊断工具等
  • 生产形式化代码的工具

 

 


JVM内存区域划分

  • 程序计数器,PC Program Counter Register会存储当前线程正在执行的Java方法的JVM指定地址,如是本地方法
  • 则是未知的地址
  • Java虚拟机栈,内部保存一个个的栈帧,对应着一次次的Java方法调用
  • 堆,内存管理的核心区域
  • 方法区,以前叫持久代,现在叫元数据区
  • 运行时常量
  • 本地方法栈

内存结构图如下

Java核心技术36讲-进阶部分_第12张图片


OutOfMemoryError类型

  • java heap space
  • StackOverFlowError
  • PermGen space
  • Metaspace (1.8之后的元数据区)
  • 本地内存的OOM
  • 编译字节码的Code Cache
     

内存划分

老年代,from-to,Eden,持久带

Java核心技术36讲-进阶部分_第13张图片

每个线程都有一个私有缓存区域TLB

Java核心技术36讲-进阶部分_第14张图片

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 构造函数等手段,排查可能的问题。
 


GC类型

Serial GC
ParNewGC
ParrallelGC
CMS
G1

垃圾收集算法

  • 引用计数
  • 选择的可达性分析

垃圾收集算法

  • 复制 Copying
  • 标记-清除 Mark-Sweep
  • 标记-整理 Mark-Compact

GC发展趋势

  • JDK10以后G1就是默认的
  • SerialGC设计简单,在小场景,Serverless下也会有用
  • CMS因为算法理论缺陷等,在未来可能会被移除
  • JDK11中增加了两个新GC
  • Epsilon GC,简单的说就是一个不做垃圾收集的GC,有点情况下如在进行性能测试的时候,需要明确判断GC本身的开销
  • ZGC,Oracle开发的,支持T级别堆大小,保证大部分情况下延迟不会超过10ms
  • 其他厂商提供的各种GC,如Zing,Shenandoah等

 

 

GC调优

内存占用 footprint
延迟      latency
吞吐量     throughput

G1 GC的内部结构和主要机制

Java核心技术36讲-进阶部分_第15张图片

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内部的运行角度,正常运行的状态流转变化

Java核心技术36讲-进阶部分_第16张图片

G1 相关概念非常多,有一个重点就是 Remembered Set,用于记录和维护 region 之间对象的引用关系。为什么需要这么做呢?试想,新生代 GC 是复制算法,也就是说,类似对象从 Eden 或者 Survivor 到 to 区域的“移动”,其实是“复制”,本质上是一个新的对象。在这个过程中,需要必须保证老年代到新生代的跨区引用仍然有效。

G1 的很多开销都是源自 Remembered Set,例如,它通常约占用 Heap 大小的 20% 或更高,这可是非常可观的比例。并且,我们进行对象复制的时候,因为需要扫描和更改 Card Table 的信息,这个速度影响了复制的速度,进而影响暂停时间。

Java核心技术36讲-进阶部分_第17张图片


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

 


JMM

具体表现形式包括但远不止是我们直觉中的 synchronized、volatile、lock 操作顺序等方面

  • 线程内执行的每个操作,都保证 happen-before 后面的操作,这就保证了基本的程序顺序规则,这是开发者在书写程序时的基本约定。
  • 对于 volatile 变量,对它的写操作,保证 happen-before 在随后对该变量的读取操作。
  • 对于一个锁的解锁操作,保证 happen-before 加锁操作。
  • 对象构建完成,保证 happen-before 于 finalizer 的开始动作。
  • 甚至是类似线程内部操作的完成,保证 happen-before 其他 Thread.join() 的线程等。
  • 这些 happen-before 关系是存在着传递性的,如果满足 a happen-before b 和 b happen-before c,那么 a happen-before c 也成立。

JMM之前的状况

  • 不能保证一些多线程程序的正确性,例如最著名的就是双检锁(Double-Checked Locking,DCL)的失效问题,具体可以参考我在第 14 讲对单例模式的说明,双检锁可能导致未完整初始化的对象被访问,理论上这叫并发编程中的安全发布(Safe Publication)失败。
  • 不能保证同一段程序在不同的处理器架构上表现一致,例如有的处理器支持缓存一致性,有的不支持,各自都有自己的内存排序模型。

所以,Java 迫切需要一个完善的 JMM,能够让普通 Java 开发者和编译器、JVM 工程师,能够清晰地达成共识。换句话说,可以相对简单并准确地判断出,多线程程序什么样的执行序列是符合规范的。


对于编译器、JVM 开发者,关注点可能是如何使用类似内存屏障(Memory-Barrier)之类技术,保证执行结果符合 JMM 的推断。
对于 Java 应用开发者,则可能更加关注 volatile、synchronized 等语义,如何利用类似 happen-before 的规则,写出可靠的多线程应用,而不是利用一些“秘籍”去糊弄编译器、JVM。

Java核心技术36讲-进阶部分_第18张图片

JVM 内部的运行时数据区,但是真正程序执行,实际是要跑在具体的处理器内核上。你可以简单理解为,把本地变量等数据从内存加载到缓存、寄存器,然后运算结束写回主内存。可以从下图中看到两种模型的对应

Java核心技术36讲-进阶部分_第19张图片

JMM 内部的实现通常是依赖于所谓的内存屏障,通过禁止某些重排序的方式,提供内存可见性保证,也就是实现了各种 happen-before 规则。与此同时,更多复杂度在于,需要尽量确保各种编译器、各种体系结构的处理器,都能够提供一致的行为。

对于一个 volatile 变量:

  • 对该变量的写操作之后,编译器会插入一个写屏障。
  • 对该变量的读操作之前,编译器会插入一个读屏障。

内存屏障能够在类似变量读、写操作之后,保证其他线程对 volatile 变量的修改对当前线程可见,或者本地修改对其他线程提供可见性。换句话说,线程写入,写屏障会通过类似强迫刷出处理器缓存的方式,让其他线程能够拿到最新数值。

 

 

你可能感兴趣的:(系统)