Fork me on GitHub

JVM(1)

Java & 内存相关

副标题:《深入理解 Java 虚拟机》读书笔记(1)

一、Java 须知

1.1 组成

  • Java 技术体系组成:
    1. ① Java 程序设计语言;
    2. ② 各种硬件平台上的Java 虚拟机;
    3. ③ Class 文件格式;
    4. ④ Java API 类库;
    5. ⑤ 第三方 Java 类库(商业机构/开源社区);
  • JDK = ①+②+④,是进行 Java 程序开发的最小环境;
  • JRE = ②+④中的部分子集(Java SE API),是支持Java程序运行的标准环境。
  • Java 技术体系平台:
    1. Java Card:支持一些 Java 小程序运行在小内存设备的平台(用在SIM卡、提款卡上);
    2. Java ME(micro edition):支持 Java 程序运行在移动端的平台,使用精简后的 Java API,也被称为 J2ME(用在手机、PDA 上);
    3. Java SE(standard edition):支持面向桌面级应用的平台,使用完整的 Java 核心 API,也被称为 J2SE(用在桌面软件);
    4. Java EE(Enterprise edition):支持使用多层架构的企业应用(如常见的 MVC 等)的平台,除了 Java SE API 外,还做了扩充,称为 J2EE(用在 ERP、CRM 应用)。

1.2 Java 各版本简史

  1. Java 1.0:“Write once,Run Anywhere”;JDK 1.0:Java JVM,Applet(运行在支持Java的Web浏览器中),AWT 等;
  2. JDK 1.1:Jar 格式,JDBC,JavaBeans,RMI(Remote Procedure Invocation,一个 JVM 对象调用另一个 JVM 对象上的方法),语法:Inner Class 和 Reflection;
  3. JDK 1.2:把 Java 系统分拆为三个方向:J2SE、J2ME和J2EE;出现了 EJB、Java Plug-in,Swing,JIT,strictfp,Collections,HotSpot;
  4. JDK 1.3:部分类库(数学运算和新的 Timer API上),JNDI,Java 2D 改进,JavaSound 类库。
  5. JDK 1.4:正则表达式、异常链、NIO、日志类、XML 解析器、XSLT 转换器;
  6. JDK 1.5:Java 语法改进:自动装箱、泛型、动态注解、枚举、可变长参数、foreach 循环;改进了内存模型(JMM,Java Memory Model),提供了 java.util.concurrent 并发包;
  7. JDK 1.6:提供动态语言支持(动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译器)、编译 API 和微型 HTTP 服务器 API;JVM 内部改进:锁和同步、垃圾收集、类加载;
  8. JDK 1.7:新的G1收集器,加强对非Java语言的调用支持,升级类加载架构;
  9. JDK 8
  10. JDK 9
  11. JDK 10

二、Java 内存

2.1 运行时数据区域

  • 包括:方法区、堆、虚拟机栈、本地方法栈、程序计数器
    • 方法区和堆是所有线程共享,另外三个是线程隔离的。
    • JDK 8 版本中,方法区被换成了 metaspace(元数据空间)

jvm模型

2.1.1 程序计数器

  • 推荐阅读:01-JVM 内存模型:程序计数器
  • 当前线程所执行的字节码的行号指示器。
  • java代码编译成字节码后,在尚未经过 JIT(实时编译器)编译前,要通过“字节码解释器”进行解释执行,流程:
    1. 解释器读取载入内存中的字节码,按照顺序读取指令;
    2. 读入指令后,将该指令翻译成固定的操作,完成操作进行分支、循环、跳转等过程;
    3. 按照以上流程是不需要计数器的。但多线程的实现,要求完成线程的切换,所以需要程序计数器。而且,每个线程都需要一个独立地程序计数器。
  • 特点:
    1. 如果执行 native 方法,程序计数器的值为 undefined,因为 native 方法是 java 通过 JNI 直接调用本地 C/C++ 库(理解:C/C++ 给 java 的一个接口,java 调用此接口从而调用 C/C++ 方法,自然不需要字节码,此时的内存分配不是 JVM 管理的);
    2. 如果执行 Java 方法,此时计数器就的是正在执行的虚拟机字节码指令的地址;
    3. 此内存区域是唯一一个没有规定任何 OutOfMemoryError 情况的区域。

2.1.2 Java 虚拟机栈

  • 是线程私有的,生命周期同线程。
  • 虚拟机栈描述的是 Java 方法执行的内存模型(即方法在执行的同时,内存区的动作):每个方法执行时都会创建一个栈帧(Stack Frame),用来存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完成的过程,对应栈帧在虚拟机栈中入栈和出栈的过程。
  • 程序员常说的“堆”与“栈”中,“栈”指的就是虚拟机栈中的局部变量表部分。
    • 局部变量表中存放了编译期可知的各种基本数据类型、对象引用和 returnAddress 类型;
    • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方式时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,而且在方法运行期间不会改变局部变量表的大小。
  • 此区域的两种异常:
    1. StackOverflowError 异常:线程请求的栈深度大于虚拟机所允许的深度,会报此异常;
    2. OutOfMemoryError 异常:如果是可以动态扩展的虚拟机(大部分 JVM 都可扩展),在扩展时无法申请到足够的内存,会报此异常。

2.1.3 本地方法栈

  • 作用类似于 Java 虚拟机栈,但本地方法栈是为 native 方法服务的,虚拟机可以自由实现它。
  • 异常也跟 Java 虚拟机栈相同。
  • 常用的 HotSpot 虚拟机将虚拟机栈和本地方法栈合二为一。

2.1.4 Java 堆

  • Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,目的是存放对象实例(几乎所有的对象实例都在这里分配内存)。目前的一些技术,如 JIT 的发展、栈上分配、标量替换优化技术等,也会允许某些对象不在堆上分配的例外。
    • 推荐阅读:Java对象分配简要流程,精华如下:
    • 逃逸分析:分析对象的动态作用域。
      • 方法逃逸:例如作为调用参数传递到其他方法中;
      • 线程逃逸:有可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量。
    • 栈上分配:如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。
  • Java 堆是占 Java 虚拟机所管理的内存中最大的一块,可以处在物理上不连续的内存空间上,只需要逻辑连续即可。在实现时也有固定式和可扩展式两种现象。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
  • Java 堆也称“GC 堆”,是垃圾收集器管理的主要区域。
    • 从内存回收角度来看,Java 堆可以细分为:新生代和老年代。再细致可分为 Eden 空间、From Survivor 空间、To Survivor 空间等;
    • 从内存分配的角度来看,Java 堆可以划分出多个线程私有的分配缓冲区(Thread Local Allocation buffer,TLAB)。在开启 TLAB 的情况下,虚拟机会为每个 Java 线程分配一块 TLAB 空间。TLAB 空间的内存非常小,如果请求对象过大时,会选择在堆中分配。
  • 扩展:对象分配内存的两种方法:指针碰撞、空闲列表。
    • 指针碰撞:假设 Java 堆中内存是规整的,用过的内存放在一边,空闲的内存放在另一边,中间设置一个分隔指示的指针,那么分配内存时,把指针横移一段与请求对象大小相同的距离,这种分配方式即指针碰撞;
    • 空闲列表:假设 Java 堆中内存不是规整的,虚拟机必须维护一个列表,记录哪些内存块是可用的,在分配时从空闲列表中选择足够大的空间划分为请求对象实例,并更新列表上的记录,这种分配方式即空闲列表。
    • Java 堆是否规整,是由所采用的垃圾收集器是否带有压缩整理功能决定的。

2.1.5 方法区(JDK 8 后的 metaspace)

  • 是各个线程共享的内存区域。
  • 用于存储已被虚拟机加载的类的版本、字段、方法、接口等描述信息和常量池:
    • 存储的类型信息:此类型的完整有效名、此类直接父类的完整有效名(如果此类是 interface 和 java.lang.Object 则不需要父类的有效名)、此类的修饰符、直接接口的有序列表。
    • 类型的常量池:JVM 为每个已加载的类都维护一个常量池。常量池就是这个类用到的常量的一个有序集合,包括实际的常量(如 String,Integer 等)和对类、域、方法的引用等。数据项像数组项一样,是通过索引访问的。
    • 域信息/字段信息:类的所有域的相关信息以及域的声明顺序。包括:域名、域类型、域修饰符。
    • 方法信息:方法的所有信息,以及声明顺序。包括:方法名、方法返回类型(或 void)、方法参数的数量和类型(有序的)、方法的修饰符、保存方法(除 abstract 和 native 外的其他方法)的字节码(bytecodes)操作数栈和方法栈帧的局部变量区的大小、异常表等。
    • 类变量,即静态变量。方法区中为每个 non-final 类变量分配空间。
    • 指向类加载器的引用:每个被 JVM 加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。
    • 指向 Class 实例的引用:类加载过程中,虚拟机会创建该类的 Class 实例,方法区会保存对该对象的引用。通过 Class.forName(String className)来查找获得该实例的引用,然后创建该类的对象。
    • 方法表:JVM 可能对每个装载的非抽象类,都创建一个数组,数组的每个元素是方法的直接引用(实例可能会调用的),包括父类中继承来的方法。在抽象类和接口中不存在此方法表。
    • 运行时常量池:当 java 文件被编译成 class 文件后,会生成 class 文件常量池。而 JVM 在执行某个类时,必须经过加载、连接(又细分为验证、准备、解析三个阶段)、初始化。当类加载到内存中后,JVM 会将 class 常量池中的内容存放到运行时常量池中。在经过解析(resolve)后,把符号引用替换为直接引用,解析的过程会去查询全局字符串池,以保证运行时常量池所引用的字符串与全局字符串池(StringTable)中所引用的是一致的。
      • class文件常量池:用于存放编译器生成的各种字面量(literal,即常量概念,比如文本字符串、被声明为 final 的常量值等)和符号引用(Symbolic References,起到无歧义定位到目标的作用)。
  • 推荐阅读:java 方法区究竟存储了什么?

2.1.6 直接内存 DirectMemory

  • 使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
  • 相对于堆内内存,堆外内存的优点:
    1. 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)
    2. 加快了复制的速度。因为堆内在flush到远程时,会先复制到直接内存(非堆内存),然后再发送;而堆外内存相当于省略掉了这个工作。
  • 缺点:
    1. 堆外内存难以控制,如果内存泄漏,那么很难排查;
    2. 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。

三、new 一个对象时发生了什么

3.1 JVM 遇到一条new指令后

  1. 首先检查这个指令的参数能否在常量池中定位到一个类的符号引用;然后检查这个符号引用代表的类是否已经被加载、解析和初始化过;如果没有,那必须执行相应的类加载过程。
  2. 类加载检查通过后,JVM 为新生对象分配内存(采用指针碰撞或空闲列表方式)。
    • 如果分配内存的操作因为并发而线程不安全,那么需要采用两种方法来避免:采用 CAS 保证更新内存指针操作的原子性;采用 TLAB 将指针操作划分在不同的空间进行。
  3. 内存分配完成后,JVM 需要将分配到的内存空间都初始化为零值,此步保证了对象的实例字段在 Java 代码中可以不赋初始值就可以直接使用。
  4. 接着,JVM 要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的 Hash 码、对象的 GC 分代年龄等信息。这些信息放在对象的 Object Header 之中。
  5. 虚拟机要做的事情基本完成,但是还需要执行<init>方法,把对象按照程序员的意愿进行初始化,才会诞生一个真正可用的对象。

3.2 对象的内存布局

对象在内存中存储的布局分为 3 个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

  • 对象头的作用是通过标记字段存储自身的运行时数据、以及通过类型指针确定此对象是哪个类的实例。
  • 实例数据是对象真正存储的有效信息,包括程序代码中定义的各种类型(自己定义的,或者从父类继承的)都记录进来。
  • 对齐填充起着占位符的作用,用来将整个对象的大小凑成 8 的整数倍。

3.3 对象的访问定位

栈上的 reference 数据是指向对象的引用,利用引用操作堆上的具体对象。但这个引用采用何种方式去定位、访问堆中的对象的具体位置呢?目前主流的访问方式有:使用句柄和直接指针两种。

  1. 使用句柄:在 Java 堆中划分一块内存作为句柄池,reference(在栈的本地变量表中) 中存储对象的句柄地址(句柄池中),句柄中包含了对象实例数据(实例池中)和类型数据各自的具体地址信息(方法区)。好处是:reference 中存储的是稳定的句柄地址,在对象被移动时,只会改变句柄中的实例数据指针,reference 本身不需要修改。
  2. 直接指针:reference 中存储的是对象地址。好处是:速度更快,节省了一次指针定位的时间开销,HotSpot 使用的就是直接指针的方式。

3.4 Java 内存溢出

  • 推荐阅读:[JVM 垃圾回收 GC Roots Tracing](GC Roots Tracing)
  • 如果是内存泄漏,可查看泄漏对象到 GC Roots 的引用链,之后就可以定位到泄漏代码处。
  • 如果是不是内存泄漏,那么内存中的对象还存活着,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长等情况,尝试减少程序运行期的内存消耗。

3.4.1 虚拟机栈和本地方法栈溢出

  • 如果线程请求的栈深度大于 JVM 允许的最大深度,抛出 StackOverflowError 异常;
  • 如果 JVM 在扩展栈时无法申请到足够的内存空间,抛出 OutOfMemoryError 异常,此异常难以出现,一般也是抛出 StackOverflowError 异常的。
  • 栈深大约 1000 ~ 2000 左右,对于一般的方法调用是足够了。但如果建立了过多线程导致的内存溢出,只能采取减少线程数、减少最大堆、减少栈容量来解决。

3.4.2 方法区和运行时常量池溢出

  • 是一种常见的内存溢出异常,在经常动态生成大量 Class 应用的情况,以及 CGLib 字节码增强、动态语言、大量 JSP、基于 OSGi 的应用等。

3.4.3 本机直接内存溢出

  • 直接内存,DirectMemory容量默认跟 Java 堆一般大小,因直接内存异常导致的异常,最明显的特征是在 Heap Dump 文件中不会看见明显的异常。如果发现 OOM(OutOfMemory)后 Dump 文件很小,而程序中又直接或间接的使用了 NIO ,那么可以检查直接内存是否溢出。
-------------The End-------------