Java堆内存分配

Java堆内存分配

ava堆内存:概述、堆的结构、对象的内存布局

Java堆内存的概述:

Java堆用来存放应用系统创建的对象和数组,所有线程共享Java堆

  1. Java堆内存在逻辑上是连续的,在物理上是不用连续的

    实现的时候,堆的大小可以是固定的也可以是扩展的,在不能扩展的时候进行扩展会抛出运行时异常(RunOutOfMerroy)

❤:Java堆是在运行期动态分配内存大小,自动进行垃圾回收

Java垃圾回收(GC)主要就是回收堆内存,对分代GC来说,堆也是分代的

1.JVM中共享数据空间可以分成三个大区,新生代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation),其中JVM堆分为新生代和老年代

2.新生代可以划分为三个区,Eden区(存放新生对象),两个幸存区(From Survivor和To Survivor)(存放每次垃圾回收后存活的对象)

3.永久代管理class文件、静态对象、属性等(JVM uses a separate region of memory, called the Permanent Generation (orPermGen for short), to hold internal representations of java classes. PermGen is also used to store more information )

4.JVM垃圾回收机制采用“分代收集”:新生代采用复制算法,老年代采用标记清理算法。

作为操作系统进程,Java 运行时面临着与其他进程完全相同的内存限制:操作系统架构提供的可寻址地址空间和用户空间。

操 作系统架构提供的可寻址地址空间,由处理器的位数决定,32 位提供了 2^32 的可寻址范围,也就是 4,294,967,296 位,或者说 4GB。而 64 位处理器的可寻址范围明显增大:2^64,也就是 18,446,744,073,709,551,616,或者说 16 exabyte(百亿亿字节)。

地址空间被划分为用户空间和内核空间。内核是主要的操作系统程序和C运行时,包含用于连接计算机硬件、调度程序以及提供联网和虚拟内存等服务的逻辑和基于C的进程(JVM)。除去内核空间就是用户空间,用户空间才是 Java 进程实际运行时使用的内存

默认情况下,32 位 Windows 拥有 2GB 用户空间和 2GB 内核空间。在一些 Windows 版本上,通过向启动配置添加 /3GB 开关并使用 /LARGEADDRESSAWARE 开关重新链接应用程序,可以将这种平衡调整为 3GB 用户空间和 1GB 内核空间。在 32 位 Linux 上,默认设置为 3GB 用户空间和 1GB 内核空间。一些 Linux 分发版提供了一个hugemem内核,支持 4GB 用户空间。为了实现这种配置,将进行系统调用时使用的地址空间分配给内核。通过这种方式增加用户空间会减慢系统调用,因为每次进行系统调用时,操作系统必须在地址空间之间复制数据并重置进程地址-空间映射。

下图为一个32 位 Java 进程的内存布局:

可寻址的地址空间总共有 4GB,OS 和 C 运行时大约占用了其中的 1GB,Java 堆占用了将近 2GB,本机堆占用了其他部分。请注意,JVM 本身也要占用内存,就像 OS 内核和 C 运行时一样。

注意:

  1. 上文提到的可寻址空间即指最大地址空间。

  2. 对于2GB的用户空间,理论上Java堆内存最大为1.75G,但一旦Java线程的堆达到1.75G,那么就会出现本地堆的Out-Of-Memory错误,所以实际上Java堆的最大可使用内存为1.5G。

    在JVM运行时,可以通过配置以下参数改变整个JVM堆的配置比例

  3. Java heap的大小(新生代+老年代)

    -Xms堆的最小值

    -Xmx堆空间的最大值

2.新生代堆空间大小调整

-XX:NewSize新生代的最小值

-XX:MaxNewSize新生代的最大值

-XX:NewRatio设置新生代与老年代在堆空间的大小

-XX:SurvivorRatio新生代中Eden所占区域的大小

3.永久代大小调整

-XX:MaxPermSize

4.其他

 -XX:MaxTenuringThreshold,设置将新生代对象转到老年代时需要经过多少次垃圾回收,但是仍然没有被回收

在上面的配置中,老年代所占空间的大小是由-XX:SurvivorRatio这个参数进行配置的,看完了上面的JVM堆空间分配图,可能会奇怪,为啥新生代空间要划分为三个区Eden及两个Survivor区?有何用意?为什么要这么分?要理解这个问题,就得理解一下JVM的垃圾收集机制(复制算法也叫copy算法),步骤如下:

复制(Copying)算法

将内存平均分成A、B两块,算法过程:

  1. 新生对象被分配到A块中未使用的内存当中。当A块的内存用完了, 把A块的存活对象对象复制到B块。

  2. 清理A块所有对象。

  3. 新生对象被分配的B块中未使用的内存当中。当B块的内存用完了, 把B块的存活对象对象复制到A块。

  4. 清理B块所有对象。

  5. goto 1。

    优点:简单高效。缺点:内存代价高,有效内存为占用内存的一半。

    图解说明如下所示:(图中后观是一个循环过程)

对复制算法进一步优化:使用Eden/S0/S1三个分区

平均分成A/B块太浪费内存,采用Eden/S0/S1三个区更合理,空间比例为Eden:S0:S1==8:1:1,有效内存(即可分配新生对象的内存)是总内存的9/10。

算法过程:

  1. Eden+S0可分配新生对象;

  2. 对Eden+S0进行垃圾收集,存活对象复制到S1。清理Eden+S0。一次新生代GC结束。

  3. Eden+S1可分配新生对象;

  4. 对Eden+S1进行垃圾收集,存活对象复制到S0。清理Eden+S1。二次新生代GC结束。

  5. goto 1。

    默认Eden:S0:S1=8:1:1,因此,新生代中可以使用的内存空间大小占用新生代的9/10,那么有人就会问,为什么不直接分成两个区,一个区占9/10,另一个区占1/10,这样做的原因大概有以下几种

  6. S0与S1的区间明显较小,有效新生代空间为Eden+S0/S1,因此有效空间就大,增加了内存使用率

  7. 有利于对象代的计算,当一个对象在S0/S1中达到设置的XX:MaxTenuringThreshold值后,会将其分到老年代中,设想一下,如果没有S0/S1,直接分成两个区,该如何计算对象经过了多少次GC还没被释放,你可能会说,在对象里加一个计数器记录经过的GC次数,或者存在一张映射表记录对象和GC次数的关系,是的,可以,但是这样的话,会扫描整个新生代中的对象, 有了S0/S1我们就可以只扫描S0/S1区了~

Java堆的结构:

新生代用来放新分配的对象;新生代中经过垃圾回收,没有回收掉的对象,被复制到老年代

老年代存储对象比新生代存储的对象的年龄大得多

老年代存储一些大对象

整个堆大小 = 新生代 + 老年代

新生代 = Eden + 存活区

❤:从前的持久代,用来存放Class、Method等元信息的区域,从JDK8开始去掉了,取而代之的是元空间(MetaSpace),元空间并不在虚拟机里面,而是直接使用本地内存

对象在内存布局

对象在内存中存储的布局(这里以HotSpot虚拟机为例来说明),分为:对象头、实例数据和对齐填充

对象头,包含两个部分:

1)Mark Word:存储对象自身的运行数据,如:HashCode、GC分代年龄、锁状态标志等

2)类型指针:对象指向它的类元数据的指针

实例数据

真正存放对象实例数据的地方

对其填充:

这部分不一定存在,也没有什么特别含义,仅仅是占位符。

因为HotSpot要求对象起始地址都是8字节的整数倍,如果不是,就对齐

对象的访问定位

对象的访问定位:在JVM规范中只规定了reference类型是一个指向对象的应用,但没有规定这个应用具体如何去定位、访问堆中对象的具体位置

❤:因此对象的访问方式取决于JVM的实现:目前主流的有:

  1. 使用句柄
    1. 使用指针

使用句柄:Java堆中会划分出一块内存来做为句柄池,reference中储存句柄的地址,句柄中储存对象的实例数据和类元数据的地址,如下图所示:

使用指针:Java堆中会存放类元数据的地址,reference存储的就直接是对象的地址,如下图所示:

(HotSpot采用指针的方式进行访问)

Java堆内存:重点是理解Java堆内存的特点、掌握堆的结构,了解对象的内存布局

---- The end of this article ----