Java内存结构

1 篇文章 0 订阅

前文:

《Java8之类的加载》

《Java8之类加载机制class源码分析》

        写在开头:本文为学习后的总结,可能有不到位的地方,错误的地方,欢迎各位指正。

        JVM 在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。如下图所示(展示效果为JDK1.6版本的模型):

        可以从图中看出JVM内存结构可以分为线程内与线程外两部分,线程内的是单个线程

  • 线程私有:程序计数器、虚拟机栈、本地方法区
  • 线程共享:堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)

目录

一、程序计数器

二、Java 虚拟机栈

        1、概要

        2、 栈帧的内部结构

        2.1、局部变量表

        槽 Slot

        2.2、操作数栈

        栈顶缓存(Top-of-stack-Cashing)

        2.3、动态链接

        JVM 是如何执行方法调用的

        2.4、方法返回地址

        3、栈的运行原理

        4、栈内异常

三、本地方法栈

四、堆

        1、概要

        2、堆内存划分

        年轻代

        老年代

3、设置堆内存大小和 OOM

        4、查看 JVM 堆内存分配

        5、TLAB

        5.1、TLAB是什么(Thread Local Allocation Buffer)

        5.2、为什么要有 TLAB ?

        6、堆外对象分配

        6.1、逃逸分析

        6.2、代码优化之同步省略(消除)

        6.3、代码优化之标量替换

        6.4、代码优化之栈上分配

七、方法区

        1、概述

        2、方法去、永久代与元空间

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace)

        方法区内存设置与垃圾回收

        3、运行时常量池

        4、字符串常量池

        JDK 1.7 为什么要将字符串常量池移动到堆中

        5、JDK演进过程

八、直接内存

九、内存分配过程

        1、流程

        类加载检查

        分配内存

        内存分配并发问题

        初始化零值

        设置对象头

        执行 init 方法

        2、样例演示

        3、对象的内存布局

        4、对象的访问定位

        句柄

        直接指针

十、OutOfMemoryError

        什么是 OutOfMemoryError

        堆空间溢出

        分析方法

        内存泄漏

        内存溢出

        GC 开销超过限制

        永久代、元空间不足

        直接内存溢出

        StackOverflowError


一、程序计数器

        程序计数器(Program Counter Register)用来存储指向下一条指令的地址,即将要执行的指令代码。

        因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

        由于多线程在一个特定的时间段内只会执行其中某一个线程方法,CPU会不停的做任务切换,这样必然会导致经常中断或恢复。为了能够准确的记录各个线程正在执行的当前字节码指令地址,所以为每个线程都分配了一个PC寄存器,每个线程都独立计算,不会互相影响。

  • 如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
  • 如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)

 程序计数器是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域        

二、Java 虚拟机栈

        1、概要

        Java 虚拟机栈(Java Virtual Machine Stacks)与程序计数器一样也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。

        每个 Java 方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、常量池引用等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

        方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。

        2、 栈帧的内部结构

        栈帧的内部结构每个栈帧(Stack Frame)中存储着:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)(或称为表达式栈)
  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用方法
  • 返回地址(Return Address):方法正常退出或异常退出的地址一些附加信息

        2.1、局部变量表

        主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。                                        ​​​​​​

        槽 Slot

        局部变量表最基本的存储单元是 Slot(变量槽)在局部变量表中,32 位以内的类型只占用一个 Slot(包括returnAddress类型),64 位的类型(long和double)占用两个连续的 Slotbyte、short、char 在存储前被转换为int,boolean也被转换为int,0 表示 false,非 0 表示 truelong 和 double 则占据两个 Slot。

        JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,索引值的范围从 0 开始到局部变量表最大的 Slot 数量当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可。(比如:访问 long 或 double 类型变量,不允许采用任何方式单独访问其中的某一个 Slot)。

        如果当前帧是由构造方法或实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

        局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

        2.2、操作数栈

        操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

        每个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称为表达式栈(Expression Stack)操作数栈,在方法执行过程中,根据字节码指令,往操作数栈中写入数据或提取数据,即入栈(push)、出栈(pop)某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。比如,执行复制、交换、求和等操作。

        每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性的 max_stack 数据项中栈中的任何一个元素都可以是任意的 Java 数据类型 32bit 的类型占用一个栈单位深度64bit 的类型占用两个栈单位深度操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。

        栈顶缓存(Top-of-stack-Cashing)

        基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁的执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM 设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理 CPU 的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

        2.3、动态链接

        在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

        JVM 是如何执行方法调用的

        方法调用不同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。Class 文件的编译过程中不包括传统编译器中的连接步骤,一切方法调用在 Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。也就是需要在类加载阶段,甚至到运行期才能确定目标方法的直接引用。

        在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关

  • 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接:如果被调用的方法在编译期无法被确定下来,也就是说,只能在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接

        2.4、方法返回地址

        用来存放调用该方法的 PC 寄存器的值,Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出,方法退出后都返回到该方法被调用的位置。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。

        方法正常退出时,调用者的 PC 计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。

        3、栈的运行原理

        JVM 直接对 Java 栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后进先出”原则

        在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。

        执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

        如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,称为新的当前栈帧。不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。

        如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧Java。

        方法有两种返回函数的方式,一种是正常的函数返回,使用 return 指令,另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出。

        4、栈内异常

        栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。

        除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

        简单总结一下程序运行中栈可能会出现两种错误:

  • StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

        另外,可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小(栈的大小直接决定了函数调用的最大可达深度。),在 JDK 1.4 中默认为 256K,而在 JDK 1.5+ 默认为 1M: 

java -Xss2M HackTheJava

三、本地方法栈

        本地方法栈(Native Method Stack) 与虚拟机栈的作用相似。

        二者的区别在于:虚拟机栈为 Java 方法服务;本地方法栈为 Native 方法服务。本地方法并不是用 Java 实现的,而是由 C 语言实现的。

        注意:本地方法栈也会抛出 StackOverflowError 异常和 OutOfMemoryError 异常。

四、堆

        1、概要

        堆(heap)是Java虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

        之所以说是几乎,是因为随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术使得所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

        2、堆内存划分

        Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

        在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  • 新生代内存(Young Generation)
  • 老生代(Old Generation)
  • 永久代(Permanent Generation)

        下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最右边一层属于永久代。

                        

        JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。 (下面介绍方法区这部分内容时会讲解)

        年轻代

        年轻代是所有新对象创建的地方。当填充年轻代时,执行垃圾收集。这种垃圾收集称为 Minor GC。年轻一代被分为三个部分——伊甸园(Eden Memory)和两个幸存区(Survivor Memory,被称为from/to或s0/s1),默认比例是8:1:1。

        大多数新创建的对象都位于 Eden 内存空间中,当 Eden 空间被对象填充满时,执行Minor GC,并将所有幸存者对象移动到一个幸存者空间中。Minor GC 检查幸存者对象,并将它们移动到另一个幸存者空间。

        所以每次,一个幸存者空间总是空的经过多次 GC 循环后存活下来的对象被移动到老年代。可以通过设置参数 -XX:MaxTenuringThreshold来干预筛选的年龄阈值,然后他们才有资格提升到老一代。

        Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
 //survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
  ...
}

        老年代

        老年代内存包含那些经过许多轮小型 GC 后仍然存活的对象。通常,垃圾收集是在老年代内存满时执行的。老年代垃圾收集称为 主GC(Major GC),通常需要更长的时间。

        大对象直接进入老年代(大对象是指需要大量连续内存空间的对象,判断标准是超过了-XX:PetenureSizeThreshold)。这样做的目的是避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝。

3、设置堆内存大小和 OOM

        Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定

  • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize
  • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

        如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。我们通常会将 -Xmx 和 -Xms 两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能

  • 默认情况下,初始堆内存大小为:电脑内存大小/64
  • 默认情况下,最大堆内存大小为:电脑内存大小/4

        4、查看 JVM 堆内存分配

        在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。默认情况下新生代和老年代的比例是 1:2,可以通过 –XX:NewRatio 来配置,新生代中的 Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过 -XX:SurvivorRatio 来配置。

        若在JDK 7 中开启了 -XX:+UseAdaptiveSizePolicy,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄。此时 –XX:NewRatio 和 -XX:SurvivorRatio 将会失效,而 JDK 8 是默认开启-XX:+UseAdaptiveSizePolicy。在 JDK 8中,不要随意关闭-XX:+UseAdaptiveSizePolicy,除非对堆内存的划分有明确的规划每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小。

        5、TLAB

        5.1、TLAB是什么(Thread Local Allocation Buffer)

        JVM 为每个线程在 Eden 空间内分配了一个私有缓存区域,使得多线程同时分配内存时,可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。

        5.2、为什么要有 TLAB ?

        因为堆区是线程共享的,任何线程都可以访问到堆区中的共享数据。由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的,为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

        尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

        一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

        6、堆外对象分配

        6.1、逃逸分析

        逃逸分析(Escape Analysis)是目前 Java 虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

        逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。

       比如下面这段

public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}

        StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个 StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,但是其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

        上述代码如果想要 StringBuffer sb不逃出方法,可以调整为不直接返回 StringBuffer,那么 StringBuffer 将不会逃逸出方法

public static String createStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb.toString();
}

        在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析,如果使用较早版本,可以通过-XX"+DoEscapeAnalysis显式开启。

        使用逃逸分析,编译器可以对代码完成如下优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  • 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器

        JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递。

        6.2、代码优化之同步省略(消除)

        由于线程同步的代价相当高,可能会降低并发性和性能,因此在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除。

public void keep() {
  Object keeper = new Object();
  synchronized(keeper) {
    System.out.println(keeper);
  }
}

        如上代码,代码中对 keeper 这个对象进行加锁,但是 keeper 对象的生命周期只在 keep()方法中,并不会被其他线程所访问到,所以在 JIT编译阶段就会被优化掉。优化成:

public void keep() {
  Object keeper = new Object();
  System.out.println(keeper);
}

        6.3、代码优化之标量替换

        标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。

        在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。

        通过 -XX:+EliminateAllocations 可以开启标量替换,-XX:+PrintEliminateAllocations 查看标量替换情况。

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point(1,2);
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

        以上代码中,point 对象并没有逃逸出 alloc() 方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point 对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

        6.4、代码优化之栈上分配

        我们通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

七、方法区

        1、概述

        方法区(Method Area)也被称为永久代。方法区用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

        运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将类在加载后进入方法区的运行时常量池中存放。

        2、方法去、永久代与元空间

        方法区(method area)只是 JVM 规范中定义的一个概念,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。

        

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace)

  1. 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。

        方法区内存设置与垃圾回收

        Java7 中我们通过-XX:PermSize 和 -xx:MaxPermSize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过-XX:MetaspaceSize (定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小)和 -XX:MaxMetaspaceSize (最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制)用来设置元空间参数。

        对于一个 64 位的服务器端 JVM 来说,其默认的 -XX:MetaspaceSize 的值为20.75MB,这就是初始的高水位线,一旦触及这个水位线,Full GC 将会被触发并卸载没用的类(即这些类对应的类加载器不再存活),然后这个高水位线将会重置,新的高水位线的值取决于 GC 后释放了多少元空间。

        如果释放的空间不足,那么在不超过 MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次,通过垃圾回收的日志可观察到 Full GC 多次调用。

        为了避免频繁 GC,建议将 -XX:MetaspaceSize 设置为一个相对较高的值。

        3、运行时常量池

        class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。字面量包括整数、浮点数和字符串字面量。常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。

        常量池表会在类加载后存放到方法区的运行时常量池中。运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。  

        4、字符串常量池

        字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

        HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。

        JDK1.7 之前,字符串常量池存放在永久代。

        JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。

        JDK 1.7 为什么要将字符串常量池移动到堆中

        主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

        5、JDK演进过程

JDK版本是否有永久代,字符串常量池放在哪里?方法区逻辑上规范,由哪些实际的部分实现的?
jdk1.6及之前有永久代,运行时常量池(包括字符串常量池),静态变量存放在永久代上这个时期方法区在HotSpot中是由永久代来实现的,以至于这个时期说方法区就是指永久代
jdk1.7有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中;这个时期方法区在HotSpot中由永久代(类型信息、字段、方法、常量)和(字符串常量池、静态变量)共同实现
jdk1.8取消永久代,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中这个时期方法区在HotSpot中由本地内存的元空间(类型信息、字段、方法、常量)和(字符串常量池、静态变量)共同实现

八、直接内存

        直接内存是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。

        直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

        JDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

        直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

九、内存分配过程

        下文节选自《Java内存区域详解》《Java 内存管理》

        1、流程

        通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程:

        类加载检查

        虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。(类的加载可以参考我之前写过的《Java8之类的加载》《Java8之类加载机制class源码分析》

        分配内存

        在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来

        分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  • 指针碰撞:
    • 适用场合:堆内存规整(即没有内存碎片)的情况下。
    • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表: 
    • 适用场合:堆内存不规整的情况下。
    • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
    • 使用该分配方式的 GC 收集器:CMS

        选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。

        内存分配并发问题

        在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。

        初始化零值

        内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

        设置对象头

        初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

        执行 init 方法

        在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

        2、样例演示

        我们用如下样例来展示下具体的内存运行过程

public class JVMCase {
	// 常量
	public final static String MAN_SEX_TYPE = "man";
	// 静态变量
	public static String WOMAN_SEX_TYPE = "woman";

	public static void main(String[] args) {
		Student stu = new Student();
		stu.setName("nick");
		stu.setSexType(MAN_SEX_TYPE);
		stu.setAge(20);
		JVMCase jvmcase = new JVMCase();
		// 调用静态方法
		print(stu);
		// 调用非静态方法
		jvmcase.sayHello(stu);
	}

	// 常规静态方法
	public static void print(Student stu) {
		System.out.println("name: " + stu.getName() + "; sex:" + stu.getSexType() + "; age:" + stu.getAge());
	}

	// 非静态方法
	public void sayHello(Student stu) {
		System.out.println(stu.getName() + "say: hello");
	}
}

class Student{
	String name;
	String sexType;
	int age;

	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	public String getSexType() {
		return sexType;
	}
	public void setSexType(String sexType) {
		this.sexType = sexType;
	}
	public int getAge() {
		return age;
	}
	public void setAge(int age) {
		this.age = age;
	}
}

        运行以上代码时,JVM 处理过程如下:

(1)JVM 向操作系统申请内存,JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给 JVM,接下来 JVM 就进行内部分配

(2)JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。

(3)class 文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值(即上文的初始化零值步骤)

(4)完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM 首先会执行构造器 <clinit> 方法,编译器会在 .java 文件被编译成 .class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 <clinit>() 方法。

(5)执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中。

(6)此时再次创建一个 JVMCase 对象,调用 sayHello 非静态方法,sayHello 方法属于对象 JVMCase,此时 sayHello 方法入栈,并通过栈中的 student 引用调用堆中的 Student 对象;之后,调用静态方法 print,print 静态方法属于 JVMCase 类,是从静态方法中获取,之后放入到栈中,也是通过 student 引用调用堆中的 student 对象。

        3、对象的内存布局

        在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

        Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

        实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

        对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

        4、对象的访问定位

        建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。

        句柄

        如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

        直接指针

        如果使用直接指针访问,reference 中存储的直接就是对象的地址。

        这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。HotSpot 虚拟机主要使用的就是这种方式来进行对象访问。

十、OutOfMemoryError

        什么是 OutOfMemoryError

        OutOfMemoryError 简称为 OOM。Java 中对 OOM 的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。通俗的解释是:JVM 内存不足了。

        在JVM 规范中,除了程序计数器区域外,其他运行时区域都可能发生 OutOfMemoryError 异常(简称 OOM)。

        堆空间溢出

        java.lang.OutOfMemoryError: Java heap space 这个错误意味着:堆空间溢出。

        更细致的说法是:Java 堆内存已经达到 -Xmx 设置的最大值。Java 堆用于存储对象实例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路径来避免垃圾收集器回收这些对象,那么当堆空间到达最大容量限制后就会产生 OOM

        堆空间溢出有可能是内存泄漏也可能是内存溢出。需要使用 jstack 和 jmap 生成 threaddump 和 heapdump,然后用内存分析工具(如:MAT)进行分析。

        分析方法

  1. 使用 jmap 或 -XX:+HeapDumpOnOutOfMemoryError 获取堆快照
  2. 使用内存分析工具(visualvm、mat、jProfile 等)对堆快照文件进行分析。
  3. 根据分析图,重点是确认内存中的对象是否是必要的,分清究竟是是内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)

        内存泄漏

        内存泄漏是指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。

        内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。内存泄漏随着被执行的次数不断增加,最终会导致内存溢出。

        内存泄漏常见场景:

  • 静态容器
    • 声明为静态(static)的 HashMap、Vector 等集合
    • 通俗来讲 A 中有 B,当前只把 B 设置为空,A 没有设置为空,回收时 B 无法回收。因为被 A 引用
  • 监听器
    • 监听器被注册后释放对象时没有删除监听器
  • 物理连接
    • 各种连接池建立了连接,必须通过 close() 关闭链接

        重点关注:

  • FGC — 从应用程序启动到采样时发生 Full GC 的次数。
  • FGCT — 从应用程序启动到采样时 Full GC 所用的时间(单位秒)。
  • FGC 次数越多,FGCT 所需时间越多,越有可能存在内存泄漏。

        如果是内存泄漏,可以进一步查看泄漏对象到 GC Roots 的对象引用链。这样就能找到泄漏对象是怎样与 GC Roots 关联并导致 GC 无法回收它们的。掌握了这些原因,就可以较准确的定位出引起内存泄漏的代码。

        导致内存泄漏的常见原因是使用容器,且不断向容器中添加元素,但没有清理,导致容器内存不断膨胀。

        内存溢出

        如果不存在内存泄漏,即内存中的对象确实都必须存活着,则应当检查虚拟机的堆参数(-Xmx 和 -Xms),与机器物理内存进行对比,看看是否可以调大。并从代码上检查是否存在某些对象生命周期过长、持有时间过长的情况,尝试减少程序运行期的内存消耗。

        GC 开销超过限制

        java.lang.OutOfMemoryError: GC overhead limit exceeded 这个错误,官方给出的定义是:超过 98% 的时间用来做 GC 并且回收了不到 2% 的堆内存时会抛出此异常。这意味着,发生在 GC 占用大量时间为释放很小空间的时候发生的,是一种保护机制。导致异常的原因:一般是因为堆太小,没有足够的内存。

        与 Java heap space 错误处理方法类似,先判断是否存在内存泄漏。如果有,则修正代码;如果没有,则通过 -Xms 和 -Xmx 适当调整堆内存大小。

        永久代、元空间不足

        java.lang.OutOfMemoryError: PermGen space、java.lang.OutOfMemoryError: Metaspace (JDK8 及以后版本) 可能是永久代(元空间)中装入了太多的类或太大的类

        Perm (永久代)空间主要用于存放 Class 和 Meta 信息,包括类的名称和字段,带有方法字节码的方法,常量池信息,与类关联的对象数组和类型数组以及即时编译器优化。GC 在主程序运行期间不会对永久代空间进行清理,默认是 64M 大小。

        在 JDK8 之前的版本中,可以通过 -XX:PermSize 和 -XX:MaxPermSize 设置永久代空间大小,从而限制方法区大小,并间接限制其中常量池的容量。

        而对于元空间而面临 OutOfMemoryError 时,如果应用程序耗尽了内存中的 Metaspace 区域,则应增加 Metaspace 的大小。更改应用程序启动配置-XX:MaxMetaspaceSize=512m

        另一种解决方案甚至更简单。你可以通过删除此参数来完全解除对 Metaspace 大小的限制,JVM 默认对 Metaspace 的大小没有限制。但是请注意以下事实:这样做可能会导致大量交换或达到本机物理内存而分配失败。

        直接内存溢出

        由直接内存导致的内存溢出,一个明显的特征是在 Head Dump 文件中不会看见明显的异常,如果发现 OOM 之后 Dump 文件很小,而程序中又直接或间接使用了 NIO,就可以考虑检查一下是不是这方面的原因。

        StackOverflowError

        对于 HotSpot 虚拟机来说,栈容量只由 -Xss 参数来决定如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。

        从实战来说,栈溢出的常见原因一般都是递归函数调用层数太深或大量循环、死循环导致。

 参考资料:

《运行时数据区域》

《Java 内存管理》

《JVM 基础 - JVM 内存结构》

《Java内存区域详解》

  • 20
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值