• 首页 首页 icon
  • 工具库 工具库 icon
    • IP查询 IP查询 icon
  • 内容库 内容库 icon
    • 快讯库 快讯库 icon
    • 精品库 精品库 icon
    • 问答库 问答库 icon
  • 更多 更多 icon
    • 服务条款 服务条款 icon

Java 并发编程:正确的理解 Java 领域中的内存模型

武飞扬头像
juejin
帮助190

前言

这些年,随着 CPU、内存、I/O 设备都在不断迭代,不断朝着更快的方向努力。在这个快速发展的过程中,有一个核心矛盾一直存在,就是这三者的速度差异。CPU 和内存的速度差异可以形象地描述为:CPU 是天上一天,内存是地上一年(假设 CPU 执行一条普通指令需要一天,那么 CPU 读写内存得等待一年的时间)。内存和 I/O 设备的速度差异就更大了,内存是天上一天,I/O 设备是地上十年。

我们都知道的是,程序里大部分语句都要访问内存,有些还要访问 I/O,根据木桶理论(一只水桶能装多少水取决于它最短的那块木板),程序整体的性能取决于最慢的操作 —— 读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  1. 现代计算机在 CPU 增加了缓存,以均衡与内存的速度差异
  2. 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异
  3. 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用

由此可见,虽然现在我们几乎所有的程序都默默地享受着这些成果,但是实际应用程序设计和开发过程中,还是有很多诡异问题困扰着我们。

基本概述

每当提起 Java 性能优化,你是否有想过,真正需要我们优化的是什么?或者说,指导我们优化的方向和目标是否明确?甚至说,我们所做的一切,是否已经达到我们的期望了呢?接下来,我们来详细探讨一下。

性能优化根据优化的方向和目标来说,大致可以分为业务优化和技术优化。业务优化产生的影响是非常巨大的,一般最常见的就是业务需求变更和业务场景适配等,当然这是产品和项目管理的工作范畴。而对于我们开发人员来说,我们需要关注的和直接与我们相关的,主要是通过一系列的技术手段,来完成我们对既定目标的技术优化。其中,从技术手段方向来看,技术优化主要可以从复用优化,结果集合优化,高效实现优化,算法优化,计算优化,资源冲突优化和 JVM 优化等七个方面着手。

一般来说,技术优化基本都集中在计算机资源和存储资源的规划上,最直接的就是对于服务器和业务应用程序相关的资源做具体的分析,在照顾性能的前提下,同时也兼顾业务需求的要求,从而达到资源利用最优的状态。一味地强调利用空间换时间的方式,只看计算速度,不考虑复杂性和空间的问题,确实有点不可取。特别是在云原生时代下和无服务时代,虽然模糊和减少了开发对这些问题的距离,但是我们更加需要了解和关注这些问题的实质。

特别指出的是,JVM 优化。由于使用 Java 编写的应用程序,本身 Java 是运行在 JVM 虚拟机上的,这就意味着它会受到 JVM 的制约。对于 JVM 虚拟机的优化。一定程度上会提升 Java 应用程序的性能。如果参数配置不当,导致内存溢出 (OOM 异常) 等问题,甚至引发比这更严重的后果。

由此可见,正确认识和掌握 JVM 结构相关知识,对于我们何尝不是一个进阶的技术方向。当然,JVM 虚拟机这一部分的内容,相对编写 Java 程序来说,更加比较枯燥无味,概念比较多且抽象,需要我们要有更多的耐心和细心。我们都知道,一颗不浮躁的心,做任何事都会收获不一样的精彩。

Java JVM 虚拟机

在开始这一部分内容之前,我们先来看一下,在 Java 中,Java 程序是如何运行的,最后又是如何交给 JVM 托管的?

1.Java 程序运行过程

作为一名 Java 程序员,你应该知道,Java 代码有很多种不同的运行方式。比如说可以在开发工具中运行,可以双击执行 jar 文件运行,也可以在命令行中运行,甚至可以在网页中运行。当然,这些执行方式都离不开 JRE,也就是 Java 运行时环境。

实际上,JRE 仅包含运行 Java 程序的必需组件,包括 Java 虚拟机以及 Java 核心类库等。我们 Java 程序员经常接触到的 JDK(Java 开发工具包)同样包含了 JRE,并且还附带了一系列开发、诊断工具。

然而,运行 C 代码则无需额外的运行时。我们往往把这些代码直接编译成 CPU 所能理解的代码格式,也就是机器码。

Java 作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实。所以呢,在运行 Java 程序之前,我们需要对其进行一番转换。

这个转换具体是怎么操作的呢?当前的主流思路是这样子的,设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。这里顺便说一句,之所以这么取名,是因为 Java 字节码指令的操作码(opcode)被固定为一个字节。

并且,我们同样可以将其反汇编为人类可读的代码格式(如下图的最右列所示)。不同的是,Java 版本的编译结果相对精简一些。这是因为 Java 虚拟机相对于物理机而言,抽象程度更高。

Java 虚拟机可以由硬件实现 [1],但更为常见的是在各个现有平台(如 Windows_x64、Linux_aarch64)上提供软件实现。这么做的意义在于,一旦一个程序被转换成 Java 字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的 “一次编写,到处运行”。

虚拟机的另外一个好处是它带来了一个托管环境(Managed Runtime)。这个托管环境能够代替我们处理一些代码中冗长而且容易出错的部分。其中最广为人知的当属自动内存管理与垃圾回收,这部分内容甚至催生了一波垃圾回收调优的业务。

除此之外,托管环境还提供了诸如数组越界、动态类型、安全权限等等的动态检测,使我们免于书写这些无关业务逻辑的代码。

2.Java 程序创建过程

 从 class 文件到内存中的类,按先后顺序需要经过加载、链接以及初始化三大步骤。其中,链接过程中同样需要验证;而内存中的类没有经过初始化,同样不能使用。那么,是否所有的 Java 类都需要经过这几步呢?

我们知道 Java 语言的类型可以分为两大类:基本类型(primitive types)和引用类型(reference types)。在上一篇中,我已经详细介绍过了 Java 的基本类型,它们是由 Java 虚拟机预先定义好的。

至于另一大类引用类型,Java 将其细分为四种:类、接口、数组类和泛型参数。由于泛型参数会在编译过程中被擦除(我会在专栏的第二部分详细介绍),因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流。

说到字节流,最常见的形式要属由 Java 编译器生成的 class 文件。除此之外,我们也可以在程序内部直接生成,或者从网络中获取(例如网页中内嵌的小程序 Java applet)字节流。这些不同形式的字节流,都会被加载到 Java 虚拟机中,成为类或接口。为了叙述方便,下面我就用 “类” 来统称它们。

无论是直接生成的数组类,还是加载的类,Java 虚拟机都需要对其进行链接和初始化。

其实,Java 虚拟机将字节流转化为 Java 类的过程,就是我们常说的 Java 类的创建过程。这个过程可分为加载、链接以及初始化三大步骤:

  • 加载是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。
  • 链接,是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。链接还分验证、准备和解析三个阶段。其中,解析阶段为非必须的。
  • 初始化,则是为标记为常量值的字段赋值,以及执行 方法的过程。类的初始化仅会被执行一次,这个特性被用来实现单例的延迟初始化。
3.Java 程序加载过程

从虚拟机视角来看,执行 Java 代码首先需要将它编译而成的 class 文件加载到 Java 虚拟机中。加载后的 Java 类会被存放于方法区(Method Area)中。实际运行时,虚拟机会执行方法区内的代码。

如果你熟悉 X86 的话,你会发现这和段式内存管理中的代码段类似。而且,Java 虚拟机同样也在内存中划分出堆和栈来存储运行时数据。

不同的是,Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。

在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。

当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

从硬件视角来看,Java 字节码无法直接执行。因此,Java 虚拟机需要将字节码翻译成机器码。

启动类加载器是由 C 实现的,没有对应的 Java 对象,因此在 Java 中只能用 null 来指代。 除了启动类加载器之外,其他的类加载器都是 java.lang.ClassLoader 的子类,因此有对应的 Java 对象。这些类加载器需要先由另一个类加载器,比如说启动类加载器,加载至 Java 虚拟机中,方能执行类加载。

在 Java 虚拟机中,这个潜规则有个特别的名字,叫双亲委派模型。每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器。在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载。

在 Java 9 之前,启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。

扩展类加载器的父类加载器是启动类加载器。它负责加载相对次要、但又通用的类,比如存放在 JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。

应用类加载器的父类加载器则是扩展类加载器。它负责加载应用程序路径下的类。(这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。)默认情况下,应用程序中包含的类便是由应用类加载器加载的。

Java 9 引入了模块系统,并且略微更改了上述的类加载器 1。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

除了由 Java 核心类库提供的类加载器外,我们还可以加入自定义的类加载器,来实现特殊的加载方式。举例来说,我们可以对 class 文件进行加密,加载时再利用自定义的类加载器对其解密。

除了加载功能之外,类加载器还提供了命名空间的作用。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。在大型应用中,我们往往借助这一特性,来运行同一个类的不同版本。

4.Java 程序编译过程

在 HotSpot 里面,上述翻译过程有两种形式:

  • 第一种是解释执行,即逐条将字节码翻译成机器码并执行;
  • 第二种是即时编译(Just-In-Time compilation,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。

前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot 默认采用混合模式,综合了解释执行和即时编译两者的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

HotSpot 采用了多种技术来提升启动性能以及峰值性能,刚刚提到的即时编译便是其中最重要的技术之一。

即时编译建立在程序符合二八定律的假设上,也就是百分之二十的代码占据了百分之八十的计算资源。

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。

理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。

举个例子,我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C 程序更高的性能。

为了满足不同用户场景的需要,HotSpot 内置了多个即时编译器:C1、C2 和 Graal。

  • Graal 是 Java 10 正式引入的实验性即时编译器,在专栏的第四部分我会详细介绍,这里暂不做讨论。之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。
  • C1 又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短。
  • C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。

从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。 为了不干扰应用的正常运行,HotSpot 的即时编译是放在额外的编译线程中进行的。HotSpot 会根据 CPU 的数量设置编译线程的数目,并且按 1:2 的比例配置给 C1 及 C2 编译器。

在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。

5.Java 虚拟机结构

从组成结构上看,一个 Java 虚拟机 (HotSpot 为例),主要包括指令集合,指令解析器,程序执行指令 等 3 个方面,其中:

  • 指令集合:指的是我们常说的字节码 (Byte Code), 主要指将源文件代码 (Source File Code) 编译运行生成的,比如在 Java 中是通过 javac 命令编译 (.java) 文件生成,而在 Python 中是通过 jython 命令来编译 (.py) 文件生成。
  • 指令解析器:主要是指字节码解释器 (Byte Code Interpreter) 和即时编译器 (JIT Compiler),比如一个 Java 虚拟机 (HotSpot 为例),就有一个字节码解释器和两个即时编译器 (Server 编译器和 Client 编译器)。
  • 程序执行指令: 主要是指操作内存区域,以装载和执行,一般是 JVM 负责 将 字节码 解释成具体的机器指令来执行。

一般来说,任何一个 Java 虚拟机都会包含这三个方面的,但是具体的有各有所不同:

  1. 字节码指令:JVM 具有针对以下任务组的字节码指令规范:加载和存储,算术,类型转换,对象创建和操作,操作数栈管理(push/pop),控制转移(分支),方法调用和返回,抛出异常,基于监视器的并发。被加载到 JVM 后可以被执行,其中字节码是实现跨平台的基础。
  2. 字节码解释器:用于将字节码解析成计算机能执行的语言,一台计算机有了 Java 字节码解释器后,它就可以运行任何 Java 字节码程序。同样的 Java 程序就可以在具有了这种解释器的硬件架构的计算机上运行,实现了 “跨平台”。
  3. JIT 即时编译器:JIT 编译器可以在执行程序时将 Java 字节码翻译成本地机器语言。一般来讲,Java 字节码经过 字节码解释器执行时,执行速度总是比编译成本地机器语言的同一程序的执行速度慢。而 即时编译器 在执行程序时将 Java 字节码翻译成本地机器语言,以显著加快整体执行时间。
  4. JVM 操作内存:JVM 有一个堆 (heap) 用于存储对象和数组。垃圾回收器要在这里工作。代码、常量和其他类数据存储在方法区 ( method area ) 中。每个 JVM 线程也有自己的调用栈 ( JVM stack ),用于存储 “帧”。每次调用方法时都会创建一个新的 帧 (放到栈里),并在该方法退出时销毁该帧。每个帧提供一个操作数堆栈 ( operand stack) 和一个局部变量数组 ( local variables )。操作数栈用于计算操作数和接收被调用方法的 "返回值",而局部变量数据用于传递 “方法参数”。

除此之外,每个特定的主机操作系统都需要自己的 JVM 和运行时实现。

6.Java GC 垃圾回收

Java 虚拟机提供了一系列的垃圾回收机制 (Garbage Collection), 又或者说是垃圾回收器 (Garbage Collector), 其中常见的垃圾回收器如下:

  • Serial GC (Serial Garbage Collection):第一代 GC,是 1999 年在 JDK1.3 中发布的串行方式的单线程 GC。一般适用于 最小化地使用内存和并行开销的场景。
  • Parallel GC (Parallel Garbage Collection):第二代 GC,是 2002 年在 JDK1.4.2 中发布的,相比 Serial GC,基于多线程方式加速运行垃圾回收,在 JDK6 版本之后成为 Hotspot VM 的默认 GC。一般是最大化应用程序的吞吐量。
  • CMS GC (Concurrent Mark Sweep Garbage Collection):第二代 GC,是 2002 年在 JDK1.4.2 中发布的,相比 Serial GC,基于多线程方式加速运行垃圾回收,可以让应用程序和 GC 分享处理器资源的 GC。一般是最小化 GC 的中断和停顿时间的场景。
  • G1 GC (Garbage First Garbage Collection):第三代 GC,是 JDK7 版本中诞生的一个并行回收器,主要是针对 “垃圾优先” 的原则而诞生的 GC,也是时下我们比较新的 GC。

在常见的垃圾回收中,我们一般采用引用计数法和可达性分析两种方式来确定垃圾是否产生,其中:

  • 引用计数法:在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用,即他们的引用计数都不为 0,则说明对象不太可能再被用到,那么这个对象就是可回收对象。
  • 可达性分析 (根搜索算法):为了解决引用计数法的循环引用问题,Java 使用了可达性分析的方法。通过一系列的 “GC roots” 对象作为起点搜索。如果在 “GC roots” 和一个对象之间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少要经过两次标记过程。两次标记后仍然是可回收对象,则将面临回收。

一般来说,当成功区分出内存中存活对象和死亡对象之后,GC 接着就会执行垃圾回收,释放掉无用对象所占用的内存空间,以便有足够可用的内存空间为新的对象分配内存。

目前,在 JVM 中采用的垃圾收集算法主要有:

  • 标记 - 清除算法 (Mark-Sweep): 最基础的垃圾回收算法,分为两个阶段,标注和清除。标记阶段标记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
  • 复制算法 (Copying): 为了解决 Mark-Sweep 算法内存碎片化的缺陷而被提出的算法。按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。这种算法虽然实现简单,内存效率高,不易产生碎片,但是最大的问题是可用内存被压缩到了原本的一半。且存活对象增多的话,Copying 算法的效率会大大降低。
  • 标记 - 压缩算法 (Mark-Compact): 为了避免缺陷而提出。标记阶段和 Mark-Sweep 算法相同,标记后不是清理对象,而是将存活对象移向内存的一端,然后清除端边界外的对象。
  • 增量算法 (Incremental Collecting): 也可以成为分区收集算法 (Region Collenting),将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。这样做的好处是可以控制一次回收多少个小区间,根据目标停顿时间,每次合理地回收若干个小区间 (而不是整个堆), 从而减少一次 GC 所产生的停顿。
  • 分代收集算法 (Generational Collenting): 是目前大部分 JVM 所采用的方法,其核心思想是根据对象存活的不同生命周期将内存划分为不同的域,一般情况下将 GC 堆划分为老生代 (Tenured/Old Generation) 和新生代 (Young Generation)。老生代的特点是每次垃圾回收时只有少量对象需要被回收,新生代的特点是每次垃圾回收时都有大量垃圾需要被回收,因此可以根据不同区域选择不同的算法。
7.Java JVM 调优

JVM 调优涉及到两个很重要的概念:吞吐量和响应时间。jvm 调优主要是针对他们进行调整优化,达到一个理想的目标,根据业务确定目标是吞吐量优先还是响应时间优先。

  • 吞吐量:用户代码执行时间 /(用户代码执行时间 GC 执行时间)。
  • 响应时间:整个接口的响应时间 (用户代码执行时间 GC 执行时间),stw 时间越短,响应时间越短。

调优的前提是熟悉业务场景,先判断出当前业务场景是吞吐量优先还是响应时间优先。调优需要建立在监控之上,由压力测试来判断是否达到业务要求和性能要求。 调优的步骤大致可以分为:

  1. 熟悉业务场景,了解当前业务系统的要求,是吞吐量优先还是响应时间优先;

  2. 选择合适的垃圾回收器组合,如果是吞吐量优先,则选择 ps po 组合;如果是响应时间优先,在 1.8 以后选择 G1,在 1.8 之前选择 ParNew CMS 组合;

  3. 规划内存需求,只能进行大致的规划。

  4. CPU 选择,在预算之内性能越高越好;

  5. 根据实际情况设置升级年龄,最大年龄为 15;

  6. 根据需要设定相关的 JVM 日志参数:

    -Xloggc:/path/name-gc-%t.log 
    		-XX: UseGCLogFileRotation 
    		-XX:NumberOfGCLogs=5
    		-XX:GCLogFileSize=20M 
    		-XX: PrintGCDetails
    		-XX: PrintGCDateStamps 
    		-XX: PrintGCCauses

    其中需要注意的是:

    -XX: UseGCLogFileRotation:GC文件循环使用
       -XX:NumberOfGCLogs=5:使用5个GC文件
       -XX:GCLogFileSize=20M:每个GC文件的大小
     

上面这三个参数放在一起代表的含义是:5 个 GC 文件循环使用,每个 GC 文件 20M,总共使用 100M 存储日志文件,当 5 个 GC 文件都使用完毕以后,覆盖第一个 GC 日志文件,生成新的 GC 文件。

当 cpu 经常飙升到 100% 的使用率,那么证明有线程长时间占用系统资源不进行释放,需要定位到具体是哪个线程在占用,定位问题的步骤如下 (linux 系统): 1. 使用 top 命令常看当前服务器中所有进程(jps 命令可以查看当前服务器运行 java 进程), 找到当前 cpu 使用率最高的进程,获取到对应的 pid; 2. 然后使用 top -Hp pid,查看该进程中的各个线程信息的 cpu 使用,找到占用 cpu 高的线程 pid 3. 使用 jstack pid 打印它的线程信息,需要注意的是,通过 jstack 命令打印的线程号和通过 top -Hp 打印的线程号进制不一样,需要进行转换才能进行匹配,jstack 中的线程号为 16 进制,而 top -Hp 打印的是 10 进制。

当内存飙高一般都是堆中对象无法回收造成,因为 java 中的对象大部分存储在堆内存中。其实也就是常见的 oom 问题 (Out Of Memory),一般: 1.jinfo pid,可以查看当前进行虚拟机的相关信息列举出来 2.jstat -gc pid ms,多长毫秒打印一次 gc 信息,打印信息如下,里面包含 gc 测试,年轻代 / 老年带 gc 信息等 3. jmap -histo pid | head -20,查找当前进程堆中的对象信息,加上管道符后面的信息以后,代表查询对象数量最多的 20 个 4. jmap -dump:format=b,file=xxx pid,可以生成堆信息的文件,但是这个命令不建议在生产环境使用,因为当内存较大时,执行该命令会占用大量系统资源,甚至造成卡顿。建议在项目启动时添加下面的命令,在发生 oom 时自动生成堆信息文件:-XX: HeapDumpOnOutOfMemory。如果需要在线上进行堆信息分析,如果当前服务存在多个节点,可以下线一个节点,生成堆信息,或者使用第三方工具,阿里的 arthas。

除此之外,我们还可以使用 jvisualvm 是 jdk 自带的图形化分析工具,可以对运行进程的线程,堆进行详细分析。但是这种分析工具可以对本地代码或者测试环境进行监控分析,不建议在线上环境使用该工具,因为它会占用系统资源。如果必须要在线上执行,建议当前服务存在多个节点,然后下线其中一个节点进行问题分析。也可以使用第三方收费的图形分析界面 jprofiler。

⚠️[注意事项] : 在日常 JVM 调优常用参数主要如下:

  • 通用 GC 常用参数:

    -Xmn:年轻代大小
    -Xms:堆初始大小
    -Xmx:堆最大大小
    -Xss:栈大小
    -XX: UseTlab:使用 tlab,默认打开,涉及到对象分配问题
    -XX: PrintTlab:打印 tlab 使用情况
    -XX: TlabSize:设置 Tlab 大小
    -XX: DisabledExplictGC:java 代码中的 System.gc () 不再生效,防止代码中误写,导致频繁触动 GC,默认不起用。
    -XX: PrintGC ( PrintGCDetails/ PrintGCTimeStamps) : 打印 GC 信息 (打印 GC 详细信息 / 打印 GC 执行时间)
    -XX: PrintHeapAtGC 打印 GC 时的堆信息
    -XX: PrintGCApplicationConcurrentTime: 打印应用程序的时间
    -XX: PrintGCApplicationStopedTime: 打印应用程序暂停时间
    -XX: PrintReferenceGC: 打印回收多少种引用类型的引用
    -verboss:class : 类加载详细过程
    -XX: PrintVMOptions : 打印 JVM 运行参数
    -XX: PrintFlagsFinal ( PrintFlagsInitial) -version | grep : 查找想要了解的命令
    -X:loggc:/opt/gc/log/path : 输出 gc 信息到文件
    -XX:MaxTenuringThreshold : 设置 gc 升到年龄,最大值为 15

  • Parallel GC 常用参数:

    -XX:PreTenureSizeThreshold 多大的对象判定为大对象,直接晋升老年代
    -XX: ParallelGCThreads 用于并发垃圾回收的线程
    -XX: UseAdaptiveSizePolicy 自动选择各区比例

  • CMS GC 常用参数:

    -XX: UseConcMarkSweepGC : 使用 CMS 垃圾回收器
    -XX:parallelCMSThreads : CMS 线程数量
    -XX:CMSInitiatingOccupancyFraction : 占用多少比例的老年代时开始 CMS 回收,默认值 68%,如果频繁发生 serial old,适当调小该比例,降低 FGC 频率
    -XX: UseCMSCompactAtFullCollection : 进行压缩整理 -XX:CMSFullGCBeforeCompaction : 多少次 FGC 以后进行压缩整理
    -XX: CMSClassUnloadingEnabled : 回收永久代
    -XX: CMSInitiatingPermOccupancyFraction : 达到什么比例时进行永久代回收
    -XX:GCTimeTatio : 设置 GC 时间占用程序运行时间的百分比,该参数只能是尽量达到该百分比,不是肯定达到
    -XX:MaxGCPauseMills : GCt 停顿时间,该参数也是尽量达到,而不是肯定达到

  • G1 GC 常用参数:

    -XX: UseG1 : 使用 G1 垃圾回收器
    -XX:MaxGCPauseMills : GCt 停顿时间,该参数也是尽量达到,G1 会调整 yong 区的块数来达到这个值
    -XX: G1HeapRegionSize : 分区大小,范围为 1M~32M,必须是 2 的 n 次幂,size 越大,GC 回收间隔越大,但是 GC 所用时间越长

JVM 内存区域

file

在 Java 虚拟机中,JVM 内存区域主要分为线程私有、线程共享、直接内存三个区域,具体详情如下:

  • 线程私有 (Theard Local Region): 数据区域生命周期与线程相同,依赖用户线程的启动 / 结束 而 创建 / 销毁 (在 Hotspot VM 内,每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存 / 否跟随本地线程的生 / 死对应)。
  • 线程共享 (Theard Shared Region): 随虚拟机的启动 / 关闭而创建 / 销毁
  • 直接内存 (Direct Memory) : 非 Java 虚拟机中 JVM 运行时数据区的一部分,但也会被频繁的使用:在 JDK 1.4 引入的 NIO 提供了基于 Channel 与 Buffer 的 IO 方式,它可以使用 Native 函数库直接分配堆外内存,然后使用 DirectByteBuffer 对象作为这块内存的引用进行操作 (详见: Java I/O 扩展), 这样就避免了在 Java 堆和 Native 堆中来回复制数据,因此在一些场景中可以显著提高性能

由此可见,在 Java 虚拟机 JVM 运行时数据区中,【程序计数器、虚拟机栈、本地方法区】属于线程私有区域,【 JAVA 堆、方法区】属于线程共享区域,都需要 JVM GC 管理的,而直接内存不受 JVM GC 管理的。

首先,对于线程私有区域中的【程序计数器、虚拟机栈、本地方法区】, 主要详情如下:

  • 程序计数器:一块较小的内存空间,是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为 “线程私有” 的内存。正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是 Native 方法,则为空。这个内存区域是唯一一个在虚拟机中没有规定任何 OutOfMemoryError 情况的区域。
  • 虚拟机栈:是描述 java 方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧( Frame)是用来存储数据和部分过程结果的数据结构,同时也被用来处理动态链接 (Dynamic Linking)、 方法返回值和异常分派( Dispatch Exception)。栈帧随着方法调用而创建,随着方法结束而销毁 —— 无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
  • 本地方法区:本地方法区和 Java Stack 作用类似,区别是虚拟机栈为执行 Java 方法服务,而本地方法栈则为 Native 方法服务,如果一个 VM 实现使用 C-linkage 模型来支持 Native 调用,那么该栈将会是一个 C 栈,但 HotSpot VM 直接就把本地方法栈和虚拟机栈合二为一。

其次,对于线程共享区域中的【 JAVA 堆、方法区】, 主要详情如下:

  • Java 堆 (Java Heap): 是 Java 虚拟机 JVM 运行时数据区中,被线程共享的一块内存区域,创建的对象和数组都保存在 Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代 VM 采用分代收集算法,因此 Java 堆从 GC 的角度还可以细分为:新生代 (Eden 区、From Survivor 区和 To Survivor 区) 和老年代。
  • 方法区 (Method Area)/ 永久代 (Permanent Generation):我们常说的永久代,用于存储被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM 把 GC 分代收集扩展至方法区,即使用 Java 堆的永久代来实现方法区,这样 HotSpot 的垃圾收集器就可以像管理 Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器 (永久带的内存回收的主要目标是针对常量池的回收和类型的卸载,因此收益一般很小)。运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java 虚拟机对 Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

其中对于 Java 虚拟机 JVM 中的 Java 堆主要分为【 新生代 、老年代 、永久代、元数据区】:

  1. 新生代 (Young Generation):用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
  2. 老年代 (Old Generation):主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以 MajorGC 不会频繁执行。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。MajorGC 的耗时比较长,因为要扫描再回收。MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
  3. 永久代 (Permanent Generation):指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域,它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
  4. 元数据区 (Metaspace): 在 Java8 中,永久代已经被移除,被一个称为 “元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由 MaxPermSize 控制,而由系统的实际可用空间来控制。

Java 内存模型

你已经知道,导致可见性的原因是缓存,导致有序性的原因是编译优化,那解决可见性、有序性最直接的办法就是禁用缓存和编译优化,但是这样问题虽然解决了,我们程序的性能可就堪忧了。

合理的方案应该是按需禁用缓存以及编译优化。那么,如何做到 “按需禁用” 呢?对于并发程序,何时禁用缓存以及编译优化只有程序员知道,那所谓 “按需禁用” 其实就是指按照程序员的要求来禁用。所以,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。

Java 内存模型是个很复杂的规范,可以从不同的视角来解读,站在我们这些程序员的视角,本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字。

Java 的内存模型是并发编程领域的一次重要创新,之后 C 、C#、Golang 等高级语言都开始支持内存模型。Java 内存模型里面,最晦涩的部分就是 Happens-Before 规则,接下来我们详细介绍一下。

Happens-Before 规则

在了解完 Java 内存模型之后,我们再来具体学习一下针对于这些问题提出的 Happens-Before 规则。如何理解 Happens-Before 呢?如果望文生义(很多网文也都爱按字面意思翻译成 “先行发生”),那就南辕北辙了,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。就像有心灵感应的两个人,虽然远隔千里,一个人心之所想,另一个人都看得到。Happens-Before 规则就是要保证线程之间的这种 “心灵感应”。所以比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

Happens-Before 规则应该是 Java 内存模型里面最晦涩的内容了,和程序员相关的规则一共有如下六项,都是关于可见性的,具体如下:

  1. 程序的顺序性规则:指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
  2. volatile 变量规则:指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。
  3. 传递性规则:指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
  4. 管程中锁的规则:指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。管程中的锁在 Java 里是隐式实现的,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
  5. 线程 start () 规则:关于线程启动的。它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。换句话说就是,如果线程 A 调用线程 B 的 start () 方法(即在线程 A 中启动线程 B),那么该 start () 操作 Happens-Before 于线程 B 中的任意操作。
  6. 线程 join () 规则:关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join () 方法实现),当子线程 B 完成后(主线程 A 中 join () 方法返回),主线程能够看到子线程的操作。当然所谓的 “看到”,指的是对共享变量的操作。换句话说就是,如果在线程 A 中,调用线程 B 的 join () 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join () 操作的返回。

在 Java 语言里面,Happens-Before 的语义本质上是一种可见性,A Happens-Before B 意味着 A 事件对 B 事件来说是可见的,无论 A 事件和 B 事件是否发生在同一个线程里。例如 A 事件发生在线程 1 上,B 事件发生在线程 2 上,Happens-Before 规则保证线程 2 上也能看到 A 事件的发生。

Java 内存模型主要分为两部分,一部分面向你我这种编写并发程序的应用开发人员,另一部分是面向 JVM 的实现人员的,我们可以重点关注前者,也就是和编写并发程序相关的部分,这部分内容的核心就是 Happens-Before 规则。

代码设计原则

对于一个开发人员来说,了解上述知识只是一个开始,更多的是我们在实际工作中如何运用。个人觉得,了解一些设计原则,并掌握这些设计原则,才能帮助我们写出高质量的代码。

当然,设计原则是代码设计时的一些经验总结。最大的一问题就就是:设计原则看起来比较抽象,其定义也比较模糊,不同的人对于同一个设计原则都会有不同的感悟。如果,我们只是单纯的抽象记忆这些定义,对于我们编程技术和代码设计的能力来说,并不会有什么实质性的帮助。

针对于每一个设计原则,我们需要掌握它能帮助我们解决什么问题和可以适合什么样的应用场景。可以这样说,设计原则是心法,设计模式是招式,而编程是实实在在的运用。常见的设计原则有:

  • 单一职责原则 (Single Responsibility Principle, SRP 原则): 一个类 (Class) 和模块 (Module) 只负责完成一个职责 (Principle) 或者功能 (Funtion).
  • 开闭原则 (Open Closed Principle, OCP 原则):软件实体,比如模块,类,方法等需要支撑 "对扩展开发,对修改关闭" 的原则。
  • 里氏替代原则 (Liskov Substitution Principle, LSP 原则):子类对象能够替代程序中的父类对象出现的任何地方,并且保证原有逻辑行为不变和正确性不被破坏。
  • 接口隔离原则 (Interface Segregation Principle, ISP 原则):接口调用方和使用者只关心自己相关的,不用依赖于自己不需要的接口。
  • 依赖反转原则 (Dependency Inversion Principle,DIP 原则):高模块不用依赖低模块,不用关注其细节,需要通过抽象来互相依赖。
  • KISS 原则 (Keep it Simple and Stupid Principle, KISS 原则):保持代码可读和可维护的原则。
  • YAGNI 原则 (You Ai Not Gonna Need It Principle,YAGNI 原则):避免过度设计的原则,不用去设计用不到的功能和不用去编写用不到的代码。
  • DRY 原则 (Do Not Repeat Yourself Principle,DRY 原则): 减少编写重复的代码的原则,提高代码复用。
  • 迪米特原则 (Law of Demeter Principle, LoD 原则): 就是我们常说的 “高内聚,低耦合” 的最佳参考原则,不应该存在直接依赖关系的类之间不要有依赖。

综上所述,前面五种原则就是我们常说的 SOLID 原则,其他四种原则也是我们最常用的原则,这些设计原则都是我们的编程方法论。

写在最后

Java 内存模型通过定义了一系列的 Happens-Before 操作,让应用程序开发者能够轻易地表达不同线程的操作之间的内存可见性。

在遵守 Java 内存模型的前提下,即时编译器以及底层体系架构能够调整内存访问操作,以达到性能优化的效果。如果开发者没有正确地利用 Happens-Before 规则,那么将可能导致数据竞争。

Java 内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作。

在设计 Java 代码的时候,遵循一些必要的设计原则,也能更好地帮助我们写出好的代码,减少内存开销,对于我们自我提升也有更好的帮助。

这篇好文章是转载于:学新通技术网

  • 版权申明: 本站部分内容来自互联网,仅供学习及演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,请提供相关证据及您的身份证明,我们将在收到邮件后48小时内删除。
  • 本站站名: 学新通技术网
  • 本文地址: /boutique/detail/tanegcj
系列文章
更多 icon
同类精品
更多 icon
继续加载