五. Java堆
1. 堆的核心概述
1. 说明
- 一个jvm实例只存在一个堆内存, 堆也是java内存管理的核心区域.
- Java堆区在jvm启动的时候即被创建, 其空间大小也就确定了. 是jvm管理的最大一块内存空间.
堆内存的大小是可以调节的 - Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放。此内存区域的唯一目的就是==存放对象实例==
- 所有的线程共享java堆, 堆中可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer,TLAB),以提升对象分配时的效率
- 几乎所有的对象实例以及数组都应当在运行时分配在堆上.
- 方法结束后, 堆中的对象不会马上被移除, 仅仅在垃圾收集的时候才会被移除
- 堆是GC(Garbage Collection, 垃圾回收器)执行垃圾回收的重要区域
- Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩 展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。
2. 内存细分
现代垃圾护手器大部分都基于分代收集理论设计.
- java7及以前堆内存逻辑上分为三部分: 新生区 + 养老区 + 永久区
- java8及以后堆内存逻辑上分为三部分: 新生区 + 养老区 + 元空间
- 约定:
新生区 == 新生代 == 年轻代
养老区 == 老年代 == 老年区
永久区 == 永久代
2. 堆空间大小的设置
1. 说明
- java堆区用于存储java对象实例, 那么堆的大小在jvm启动时就已经设定好了, 大家可以通过选项"-Xmx" 和 "-Xms"来进行设置
- "-Xms"用于表示堆区的起始内存, 等价于:
-XX:InitialHeapSize
- "-Xmx"用于表示堆区的最大内存, 等价于:
-XX:MaxHeapSize
- "-Xms"用于表示堆区的起始内存, 等价于:
- 一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时, 会抛出OutOfMemoryError异常
- 通过将-Xms和-Xmx两个参数配置相同的值, 其目的是: 为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小, 从而提高性能.
- 默认情况, 初始内存大小: 物理电脑内存大小/64; 最大内存情况: 物理电脑内存大小/4
2. 案例
-
代码
public class HeapSpaceInitial { public static void main(String[] args) { long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024; System.out.println("初始化内存大小: " + initialMemory); System.out.println("最大内存大小: " + maxMemory); } }
-
参数设置
-Xms600m -Xmx600m
-
结果
-
原因
from区和to区的大小都是25M; 而且from区和to区总是只有一个区域存放内容
3. 年轻代与老年代
1. 说明
-
存储在JVM中的java对象可以被划分为两类
-
一类是生命周期较短的瞬时对象, 这类对象的创建和消亡都非常迅速
-
另一类对象的生命周期非常长, 在某些极端情况下还能够与JVm的生命周期保持一致
-
-
JVM堆区进一步细分的话, 可以划分为年轻代(YoungGen) 和 老年代(OldGen)
-
其中年轻代又可以划分为Eden空间, Survivor0空间和Survivor1空间(有时也叫做from区, to区)
-
几乎所有的java对象都是在Eden区被new出来的
-
绝大部分的java对象的销毁都在新生代进行了
-
可以根据"-Xmn"来设置新生代最大内存的大小, 一般不设置
2. 设置占比
-
默认
-XX:NewRation=2
, 表示新生代占1, 老年代占2, 新生代占整个堆的1/3官方默认说的是
-XX:SurvivorRation=8
, 表示Eden空间和另外两个Survivor空间缺省所占的比例是8:1:1
但是实际测试为6:1:1
-Xms600m -Xmx600m
因为开启了自适应的原因,
-XX:-UseAdaptiveSizePolicy
: 关闭自适应还是不好使
-XX:SurvivorRatio=8
: 显示新生代中Eden区与survivor的内存空间的比例
-
可以修改-XX:NewRation=4, 表示新生代占1, 老年代占4, 新生代占整个堆的1/5
4. 对象分配过程
1. 一般过程
-
new的对象先放伊甸园区, 此区有大小限制
-
当伊甸园的空间填满时, 程序又需要创建对象, JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC), 将伊甸园区中的不再被其他对象所引用的对象进行销毁. 再加载新的对象到伊甸园区.
-
然后将伊甸园中的剩余对象移动到幸存者0区
-
如果再此触发垃圾回收, 此时上次幸存下来的放到幸存者0区, 如果没有回收, 就放到幸存者1区
-
如果再次经历垃圾回收, 此时会重新放回幸存者0区, 接着再去幸存者1区.
-
啥时候能去养老区呢? 可以设置次数. 默认是15次
可以设置参数: -XX:MaxTenuringThreshold=进行设置
-
在养老区, 相对悠闲. 当养老区内存不足时, 再次触发GC: MajorGC, 进行养老区的内存清理
-
若养老区执行了Major GC之后发现依然无法进行对象的保存, 就hi产生OOM异常
java.lang.OutOfMemoryError: Java heap space
-
总结:
针对幸存者s0, s1区的总结: 复制之后有交换, 谁空谁是to
关于垃圾回收: 频繁在新生区收集, 很少在养老区收集, 几乎不在永久区/元空间收集
2. 特殊情况
- 流程图
5. Minor GC, Major GC 与 Full GC
1. 说明
JVM在进行GC时, 并非每次都对上面三个内存(新生代, 老年代, 放啊去)区域一起回收的, 大部分时候回收的都是指新生代
针对HotSpot VM的实现, 它里面的GC按照回收区域又分为两大类: 一种是部分收集(Partial GC), 一种时整堆收集(Full GC)
-
部分收集: 不是完整收集整个Java堆的垃圾收集. 其中又分为:
-
新生代收集(Minor GC / Young GC): 只是新生代的垃圾收集
-
老年代收集(Major GC / Old GC): 只是老年代的垃圾收集
目前, 只有CMS GC会有单独收集老年代的行为
注意: 很多时候Major GC会和Full GC混淆使用, 需要具体分辨时老年代回收还是整堆回收.
-
-
混合收集(Mixed GC): 收集整个新生代以及部分老年代的垃圾收集
目前只有G1 GC会有这种行为
-
整堆收集(Full GC): 收集整个java堆和方法区的垃圾回收
2. 年轻代GC(Minor GC)触发机制
- 当年轻代空间不足时, 就会触发Minor GC, 这里的年轻代满指的时Eden代满, Survivor满不会引发GC.(每次Minor GC会清理年轻代的内存)
- 因为java对象大多都具备朝生夕灭的特性, 所以Minor GC非常频繁, 一般回收速度也比较快.
- Minor GC会引发STW, 暂停其它用户的线程, 等垃圾回收结束, 用户线程才恢复运行.
3. 老年代GC(Major GC)触发机制
-
出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对)
也就是在老奶奶带空间不足时, 会先尝试触发Minor GC. 如果之后空间还是不足, 着触发Major GC
-
Major GC速度一般会比Minor GC慢10倍以上, STW的时间更长.
-
如果Major GC后, 内存还是不足, 就报OOM
4. Full GC触发机制
- 调用System.gc()时, 系统建议执行Full GC, 但是不必然执行
- 老年代空间不足
- 放啊去空间不足
- 通过给Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区, survivor space0(From Space)区向survivorspace1(To Space)区复制时, 对象大小大于To Space可用内存, 则把该对象转存到老年代, 且老年代的可用内存小于该对象的大小
- 说明: full gc时开发或调优过程中尽量要避免的. 这样暂时时间会短一些
6. 堆空间分代思想
- 分代的唯一理由就是优化GC性能. 如果没有分代, 那所有的对象都在一块, GC的时候要找到哪些对现象没用, 就需要对堆的所有区域进行扫描.
- 而很多对象都是朝生夕死的,如果分代的话, 把新创建的对象放到某一地方, 当GC的时候先把这块存储"朝生夕死"对象的区域进行回收, 这样就可以腾出很大的空间出来
7. 内存分配策略
- 如果对象在Eden出生并经过第一次MinorGC后仍然存活, 并且能被Survivor容纳的话, 将被移动到Survivor空间中, 并将对象年龄设为1, 对象在Survivor区中每熬过一次MinorGC, 年龄就增加1岁, 当它的年龄增加到一定程度(默认为15岁, 其实每个JVM, 每个GC都有所不同)时, 就会倍晋升到老年代中
- 对象晋升老年代的年龄阈值, 可以通过选项
-XX: MaxTenuringThreshold
来设置.
8. 对象分配过程: TLAB
1. 什么是TLAB
- 从内存模型而不是垃圾收集的角度, 对Eden区域区域继续进行划分, JVM为每个线程分配了一个私有缓存区域, 它包含在Eden空间内.
- 多线程同时分配内存时, 使用TLAB可以避免一系列的非线程安全问题, 同时还能够提升内存分配的吞吐量, 因此我们可以将这种内存分配方式称之为快速分配策略.
- 据我所知私有OpenJDK衍生出来的JVM都提供了TLAB的设计
2. 为什么有TLAB
- 堆区是线程共享区域, 任何线程都可以访问到堆区的共享数据
- 由于对象实例的创建在jvm中非常频繁, 因此在并发环境下从堆区中划分内存空间是线程不安全的.
- 为了避免多个线程操作同一地址, 需要使用加锁等机制, 进而影响分配速度
3. TLAB再说明
- 尽管不是所有的对象实例都能够再TLAB中成功分配内存, 但JVM确实是将TLAB作为内存分配的首选
- 在程序中, 开发人员可以通过选项:
-XX:UseTLAB
设置是否开启TLAB空间 - 默认情况下, TLAB空间内存非常小, 仅占整个Eden空间的1%, 当然我们可以通过选项
-XX:TLABWasteTargetPercent
设置TLAB空间所占用Eden空间的百分比大小 - 一旦对象在TLAB空间分配内存失败时, JVM就会尝试着通过使用加锁机制确保数据操作的原子性, 从而直接在Eden空间中分配内存.
9. 堆空间的参数设置
1. 常用参数
-
-XX:PrintFlagsInitial
: 查看所有的参数的默认初始值 -
-XX:+PrintFlagsFinal
: 查看所有的参数的最终值(可能会存在修改, 不再是初始值) -
-Xms
: 初始堆空间内存(默认为物理内存的1/64) -
-Xmx
: 最大堆空间内存(默认为物理内存的1/4) -
-Xmn
: 设置新生代的大小(初始值及最大值) -
-XX:NewRatio
: 配置新生代与老年代在堆结构的占比 -
-XX:SurvivorRation
: 设置新生代中Eden和S0/S1空间的比例 -
-XX:MaxTenuringThreshold
: 设置新生代垃圾的最大年龄 -
-XX:+PrintGCDetails
: 输出详细的GC处理日志打印gc简要详细:
-XX:PrintGC
或-verbose:gc
-
-XX:HandlePromotionFailure
: 是否设置空间分配担保
10. 补充: 堆不是分配对象存储的唯一选择
1. 说明
- 随着JIT编译期的发展与逃逸分析技术逐渐成熟, 栈上分配, 标量替换优化技术将会导致一些微妙的变化, 所有的对象分配到堆上也渐渐变得不那么"绝对"了
- 如果经过逃逸分析后发现, 一个对象并没有逃逸出方法的话, 那么就可能被优化成栈上分配.这样就无需在堆上分配内存, 也无须进行垃圾回收了. 这也是最常见的堆外存储技术
2. 逃逸分析技术
如果将堆上的对象分配到栈, 需要使用逃逸分析手段
- 这是一种可以有效减少java程序中同步负载和内存堆分配压力了的跨函数卷曲数据流分析算法
- 通过逃逸分析, java hotspot编译器能够分析出一个新的对象的引用的使用范围, 从而决定是否要将这个对象分配到堆上
- 逃逸分析的基本行为就是分配对象动态作用域:
- 当一个对象在方法中被定义后, 对象只在方法内部使用, 着认为没有发生逃逸
- 当一个对象在方法中被定义后, 它被外部放啊所引用, 则认为发生逃逸.
3. 代码优化
使用逃逸分析, 编译器可以对代码做如下优化
- 栈上分配: 将堆分配转化为栈分配. 如果一个对象在子程序中被分配, 要使指向该对象的指针永远不会逃逸, 对象可能是栈分配的候选, 而不是堆分配
- 同步省略: 如果一个对象被发现只能从一个线程被访问到, 那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换.: 有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,. 那么对象的部分(或全部)可以不存储在内存, 而是存储在CPU寄存器中.
六. 方法区
1.栈, 堆, 方法区的交互关系
-
运行时方法区
-
从线程共享与否的角度来看
-
实例
2. 方法区的理解
1. 基本理解
-
尽管所有的方法区在逻辑上是属于堆的一部分, 但一些简单的实现可能不会选择区进行垃圾收集或进行压缩, 所以方法区看作是一块独立于Java堆的内存空间
-
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
-
方法区在JVM启动的时候被创建, 并且它的实际的物理内存空间中和java堆区一样都可以是不连续的
-
方法区的大小, 跟堆空间一样, 可以选择固定大小或者可扩展
-
方法区的大小决定了系统可以保存多少个类, 如果系统定义了太多的类, 导致方法区一户, 虚拟机同样会抛出内存溢出错误: OutOfMemoryError异常。
- 加载大量的第三方jar包
- 部署的工程过多(30-50)
- 大量动态的生成反射类
-
关闭jvm就会释放这个区域的内存
2. Hotspot方法区的演进
-
在jdk7及以前, 习惯上把方法区, 称为永久代, 在jdk8开始, 元空间取代了永久代
-
本质上, 方法区和永久代并不等价. 仅是堆hotspot而言的.
-
元空间的本质和永久代类似, 都是堆jvm规范中方法区的实现. 不过元空间与永久代最大的区别在于: 元空间不在虚拟机设置的内存中, 而是使用本地内存
-
永久代, 元空间二者并不只是名字变了, 内部结构也调整了
-
如果方法区无法满足新的内存分配需求时, 将抛出OOM异常.
3. 设置方法区大小与OOM
方法区的大小不必是固定的, JVM可以根据应用的需要动态调整
1. jdk7及以前
-
通过
-XX:PermSize
来设置永久代初始分配空间. 默认值时20.75M -
-XX:MaxPermSize
来设定永久代最大可分配空间. 32位机器默认时64M, 64位机器默认是82M -
当jvm加载的类信息容量超过这个值, 会报异常:
OutOfMemoryError:PermGen space
2. jdk8及以后
- 元数据去大小可以使用参数
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定, 替代上述原有的两个参数 - 默认值依赖于平台. windows下, -XX:MetaspaceSize是21M, -XX:MaxMetaspaceSize的值是-1, 即没有限制
- 与永久代不同, 如果不指定大小, 默认情况下, 虚拟机会耗尽所有的可用系统内存. 如果元数据区发生溢出, 虚拟机一样会抛出异常: OutOfMemoryError:metaspace
-XX:MetaspaceSize
: 设置初始的元空间大小, 对于一个64位的服务端jvm来说, 其默认值位21M. 这就是初始的高水位先, 一旦触及这个水位线, Full GC将会被触发并卸载没用的类, 然后这个高水位西线将会重置. 新的高水位线的值取决于GC后释放了多少元空间. 如果释放的空间不足, 那么在不超过MaxMetaspaceSize时, 十点过提高该值. 如果释放空间过多, 则适当降低该值.- 如果初始化的高水位线设置过低, 上述高水位线调整情况会发生多次. 通过垃圾回收器的日志可以观察到Full GC多次调用. 为了避免频繁的GC, 建议
-XX:MetaspaceSize
设置位一个相对较高的值.
3. 如何解决这些OOM
- 要解决OOM异常或heap space的异常, 一般的手段时首先通过内存映像分析工具对dump出来的堆转储快照进行分析, 重点是确认内存中的对象是否是必要的, 也就是要先分清楚到底是出现了内存泄漏(Memory Lear)还是内存溢出(Memory Overflow)
- 如果是内存泄漏, 可进一步通过工具查看泄漏对象到GC Roots的引用链. 于是就能找到泄漏对象是通过怎样的路径与GC Roots习惯联并导致垃圾收集器无法主动回收它们的. 掌握了泄漏对象的类型信息, 以及GC Roots引用链的信息, 就可以比较准确地定位出泄漏代码的位置
- 如果不存在内存泄漏, 换句话说就是内存中的对象确实都还必须存活着, 那就应当检查虚拟机的堆参数(-Xmx 与 -Xms), 与机器物理内存对比看是否还可以调大, 从代码上检查是否存在某些对象生命周期过长, 持有状态时间过长的情况, 尝试减少程序运行期的内存消耗
4. 方法区内部结构
方法区用于存储已被虚拟机加载的类型信息, 常量, 静态变量, 即时编译器编译后的代码缓存等.
1. 类型信息
对每个加载的类型(类class, 接口interface, 枚举enum, 注解annotation), JVM必须在方法区存储以下类型信息:
- 这个类型的完整有效名称(全名=包名.类名)
- 这个类型直接父类的完整有效名(对于interface或是java.lang.Object, 都没有父类)
- 这个类型的修饰符(public, abstract, final的某个子集)
- 这个类型直接接口的一个有序列表
2. 域(Field)信息
- java必须在方法区中保存类型的所有域的相关信息以及域的声明顺序.
- 域的相关信息包括: 域名称, 域类型, 域修饰符(public, private, protected, static, final, volatile, transient的某个子类)
3. 方法(Method)信息
jvm必须保存所有方法的以下信息, 同域信息一样包括声明顺序
-
方法的名称
-
方法的返回类型(或 void)
-
方法参数的数量和类型(按顺序)
-
方法的修饰符(public, private, protected, static, final, synchronized, native, abstract的一个子集)
-
方法的字节码(bytecodes), 操作数栈, 局部变量表及大小(abstract和native方法除外)
-
异常表(abstract和native方法除外)
每个异常处理的开始位置, 结束位置, 代码处理在程序计数器中的偏移地址, 被捕获的异常类的常量池索引
4. non-final的类变量
- 静态变量和类关联在一起, 随着类的加载而加载, 它们成为类数据在逻辑上的一部分.
- 类变量被类的所有实例共享, 即使没有类实例时也可以访问它.
5. 全局常量: static final
被声明位final的类变量的处理方法则不同, 每个全局常量在编译的时候就会被分配了.
6. 运行时常量池
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字 段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
- 行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量 一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常 量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。
- 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存 时会抛出OutOfMemoryError异常。
5. 方法区的演进细节
首先明确: 只有HotSpot才有永久代. BEA, JRockit, IBM, J9是不存在永久代的概念的
1. HotSpot中方法区的变化
-
jdk1.6及以前:
有永久代(permanent generation), 静态变量存放在永久代上
-
jdk1.7:
有永久代, 但已经逐步"去永久代", 字符串常量池, 静态变量移除, 保存在堆中
-
jdk1.8及以后:
无永久代, 类型信息, 字段, 方法, 常量保存在本地内存的元空间, 但字符串常量池, 静态变量仍在堆
2. 永久代为什么要被元空间替换
-
位用接待设置空间大小是很难确定的.
元空间和永久代之间最大的区别在于: 元空间并不在虚拟机中, 而是使用本地内存. 因此, 默认情况下, 元空间的大小仅受本地内存限制
-
对永久代进行调优是很困难的
6. 方法区的垃圾回收
-
一般来说方法区的回收效果比较难令人满意, 尤其是类型的卸载, 条件相当苛刻. 但是这部分区域的回收有时又确实是必要的.
-
方法区的垃圾回收主要回收两部分内容: 常量池中废弃的常量和不再使用的类型
-
Hotspot虚拟机对常量池的回收策略是很明确的, 只要常量池中的常量没有被任何地方引用, 就可以被回收
-
判断一个类是否不再使用, 需要同时满足下面3个条件:
- 该类所有的实例都已经被回收, 也就是Java堆中不存在该类及其任何派生之类的实例
- 加载该类的类加载器已经被回收, 这个条件除非是经过精心设计的可替换类加载器的场景, 否则通常是很难达成的
- 该类对应的java.lang.Class对象没有再任何地方被引用, 无法在任何地方通过反射访问该类的方法.
-
java虚拟机被允许对满足上述三个条件的无用类进行回收, 这里说的仅仅是"被允许", 而不是和其他对象一样, 没有引用了就必然会回收, 关于是否对类型进行回收, Hotspot虚拟机提供了-Xnoclassgc参数进行控制, 还可以使用-verbose:class以及-XX:+TraceClass-Loading, -XX:+TraceClassUnLoading查看类加载和卸载信息
-
在大量使用反射, 动态代理, CGLib等字节码框架, 动态生成JSP以及OSGi这类频繁自定义类加载器的场景中, 通常都需要Java虚拟机具备类型卸载的能力, 以保证不会对方法区造成过大的内存压力