JVM之内存管理(二)

发布于:2025-05-11 ⋅ 阅读:(25) ⋅ 点赞:(0)

部分内容来源:JavaGuide+二哥Java


说⼀下 JDK1.6、1.7、1.8 内存区域的变化?

JDK1.6、1.7/1.8 内存区域发⽣了变化,主要体现在⽅法区的实现:

JDK1.6

常量池在方法区

JDK1.7

JDK1.6 使⽤永久代实现⽅法区:JDK1.7 时发⽣了⼀些变化,将字符串常量池静态变量,存放在堆上

类常量池和运行时常量池仍然在方法区

JDK1.8

在 JDK1.8 时彻底⼲掉了永久代,⽽在直接内存中划出⼀块区域作为元空间

运行时常量池类常量池都移动到元空间(其实还是在方法区)


说一下常量池位置的变化

常量池包括:

类常量池

运行时常量池

字符串常量池

JDK1.6:常量池在永久代

JDK1.7:运行时常量池+类常量池在永久代,字符串常量池在堆

JDK1.8:运行时常量池+类常量池在元空间,字符串常量池在堆


我们的字符串常量池和静态变量存到哪里


为什么使用元空间替代永久代作为方法区的实现?

永久代有固定大小由MaxPermSize控制,元空间的可用空间由系统的实际可用空间来控制

1、整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

2、元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了

由系统的实际可用空间来控制,这样能加载的类就更多了。

3、在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

4、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低


堆里的对象年龄是什么

新生代

我们的新生代分为3个区域

Eden,S0,S1

首先在Eden区域分配,在一个新生代垃圾回收后,如果对象还存活,就会进入S0或者S1,并且对象年龄会加1

老年代

老年代:当新生对对象年龄增加到一定程度(默认为15岁),就会晋升到老年代中


方法区和永久代以及元空间是什么关系

永久代和元空间

是对方法区的两种实现方式


元空间存储什么

运行时常量池

类常量池


方法区存储什么

读取并解析Class文件获取相关信息,将信息存进方法区

方法区里面会存储已被虚拟机加载的 类信息,字段信息,方法信息,常量,静态变量,即时编译器编译后的代码缓存等数据

运行时常量池和我们的类常量池


说一下运行时常量池

常量池表

Class 文件中除了有类的版本、字段、方法、接口等描述信息外

还有用于存放编译期生成的各种字面量和符号引用的 常量池表


什么是字面量?

字面量是源代码中的固定值的表示法,即通过字面我们就能知道其值的含义。

字面量包括整数、浮点数和字符串字面量


什么是符号引用?

常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号


常量池表会在类加载后存放到方法区的运行时常量池

运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比典型符号表更广泛的数据。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。


说一下字符串常量池

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

JDK1.7之后
字符串常量和静态变量移动到堆里了
 


JDK1.7为啥要将字符串常量池移动到堆中

JDK1.7之前我们的字符串常量是存在我们的永久代中

但是永久代我们的GC回收效率太低了,只有在整堆收集(Full GC)的时候才会被执行GC

Java程序中通常会有大量的被创建的字符串等待回收,将字符串常量放到堆中,能够更高效及时地回收字符串内存


什么是整堆回收 

什么是整堆收集

整个堆内存,包括年轻代和老年代进行垃圾回收的过程


整堆收集的触发条件

1.老年代空间不足

2.方法区空间不足

3.主动调用gc,systrem.gc()进行整堆回收

4.JVM垃圾回收器自适应策略决定执行整堆回收来优化内存使用

5.新生代的对象因为老年代空间不足,导致无法晋升到老年代


什么是直接内存

什么是直接内存

是一种特殊的内存缓冲区,在本地内存上分配

我们的NIO可以通过Java堆中的DirectByteBuffer对象作为这块内存的引用操作

显著提高性能,避免了在Java堆和Native(本地)堆之间来回复制数据

说一下直接内存的优缺点

优点:

1.速度快

2.减少垃圾回收压力,直接内存不受JVM垃圾回收器的控制

缺点:

1.手动管理,因为我们的内存不受JVM管理,所以我们的JVM不会自动回收它,我们需要手动释放

2.分配和释放的成本较高,不适合频繁进行分配和释放操作


什么是堆外内存

我们不受JVM管理的内存我们都叫做堆外内存

其实我们的堆外内存包含我们的直接内存


说一下对象创建过程

 

简单区分

初始化对象

为对象分配内存

将分配的内存指向对象


详细区分

在 JVM 中对象的创建,我们从⼀个 new 指令开始

1.类加载检查

⾸先检查这个指令的参数是否能在常量池(类常量池)中定位到⼀个类的符号引⽤

检查这个符号引⽤代表的类是否已被加载、解析和初始化过。如果没有,就先执⾏相应的类加载过程

2.分配内存

类加载检查通过后,接下来虚拟机将为新⽣对象分配内存

3.初始化零值

分配内存初始化为0

内存分配完成之后,虚拟机将分配到的内存空间(但不包括对象头)都初始化为零值

4.设置对象头

接下来设置对象头

请求头⾥包含了对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息

5.执行对象初始化方法

执行初始化方法


流程图解


对象创建过程详解

对象的创建

首先我们要知道我们的对象的创建过程

类加载检查

分配内存

初始化零值

设置对象头

执行初始化方法


1.类加载检查

虚拟机遇到一个new指令时

首先将去检查这个指令的参数是否能在常量池定位这个类的符号引用

并且检查这个符号引用代表的类是否已被加载过、解析和初始化过

如果没有,那必须先执行相应的类加载过程


2.分配内存

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

分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定

而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存的两种的分配方式
指针碰撞

适用场合:堆内存规整的情况下

原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动内存位置大小即可

使用该分配方式的GC收集器:Serial,ParNew


空闲列表

适用情况:堆内存不规整的情况下

原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录

使用该分配方式的GC收集器:CMS


内存分配并发问题

虚拟机采用两种方式来保证线程安全

CAS失败重试

CAS是乐观锁的一种实现方式。

乐观锁:不加锁,假设没有冲突去完成某项操作,如果冲突失败就重试,知道成功为止

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性


TLAB

为每一个线程预先在Eden区分配一块内存

JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配


3.初始化零值

内存分配完后,虚拟机需要将分配到的内存空间初始化为零值(不包括对象头)

这一步操作保证了对象的实例字段在Java代码中可以不赋初值就直接使用

程序能访问到这些字段的数据类型所对应的零值


4.设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置

例如:这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄等信息

这些信息存放到对象头中

另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置


5.执行init方法

执行完上面的流程之后

从虚拟机视角来看:一个新的对象已经产生了

从Java程序的视角来看:对象创建才刚刚开始,init方法还没有执行,所有的字段都还为0

所以,一般来说,执行new指令之后会接着执行init方法,把对象按照程序员的意愿进行初始化,这样子一个真正可用的对象才算完全产生出来


内存分配的两种方式

指针碰撞

适用场合:堆内存规整的情况下

原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动内存位置大小即可

使用该分配方式的GC收集器:Serial,ParNew


空闲列表

适用情况:堆内存不规整的情况下

原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录

使用该分配方式的GC收集器:CMS


内存分配并发问题

虚拟机采用两种方式来保证线程安全

CAS失败重试

CAS是乐观锁的一种实现方式。

乐观锁:不加锁,假设没有冲突去完成某项操作,如果冲突失败就重试,知道成功为止

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性


TLAB:Eden区预分配内存

为每一个线程预先在Eden区分配一块内存

JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配


指针碰撞和空闲列表

 

内存分配有两种⽅式,指针碰撞(Bump The Pointer)、空闲列表(Free List)

指针碰撞

假设 Java 堆中内存是绝对规整的,所有被使⽤过的内存都被放在⼀边,空闲的内存被放在另⼀边,

中间放着⼀个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间⽅向挪动⼀段与对象⼤⼩相等的距离,这种分配⽅式称为“指针碰撞”

空闲列表

如果 Java 堆中的内存并不是规整的,已被使⽤的内存和空闲的内存相互交错在⼀起,那就没有办法简单地进⾏指针碰撞了

虚拟机就必须维护⼀个列表,记录上哪些内存块是可⽤的,在分配的时候从列表中找到⼀块⾜够⼤的空间划分给对象实例,并更新列表上的记录,这种分配⽅式称为“空闲列表”。

两种⽅式的选择由 Java 堆是否规整决定,Java 堆是否规整是由选择的垃圾收集器是否具有压缩整理能⼒决定的


JVM ⾥ new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的? 

发生抢占的情况

假设 JVM 虚拟机上,每⼀次 new 对象时,指针就会向右移动⼀个对象 size 的距离,⼀个线程正在给 A 对象分配内存,指针还没有来的及修改,另⼀个为 B 对象分配内存的线程,⼜引⽤了这个指针来分配内存,这就发⽣了抢占。

两种可选⽅案来解决这个问题

1.采⽤ CAS 分配重试的⽅式来保证更新操作的原⼦性

2.每个线程在 Java 堆中预先分配⼀⼩块内存,也就是本地线程分配缓冲区(Thread Local Allocation Buffer,TLAB).要分配内存的线程,先在本地缓冲区中分配只有本地缓冲区⽤完了,分配新的缓存区时才需要同步锁定


对象的内存布局

 

在 HotSpot 虚拟机⾥,对象在堆内存中的存储布局可以划分为三个部分:

对象头(Header)

实例数据 (Instance Data)

对⻬填充(Padding)

对象头

主要由两部分组成:

第⼀部分存储对象⾃身的运行时数据:哈希码、GC 分代年龄锁状态标志、线程持有的锁、偏向线程 ID偏向时间戳等,官⽅称它为 Mark Word,它是个动态的结构,随着对象状态变化。

第⼆部分是类型指针,指向对象的类元数据类型(即对象代表哪个类)

ps:如果对象是⼀个 Java 数组,那还应该有⼀块用于记录数组长度的数据

实例数据

⽤来存储对象真正的有效信息,也就是我们在程序代码⾥所定义的各种类型的字段内容,⽆论是从⽗类继承的,还是⾃⼰定义的。

对齐填充

不是必须的,没有特别含义,仅仅起着占位符的作⽤。


对象的内存布局(简化)

三块区域

对象头

实例数据

对齐填充


对象头的两部分信息

标记字段

用于存储对象自身的运行时数据

例如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等等

类型指针

对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例


对象访问定位的方式

Java 程序会通过栈上的 reference 数据操作堆上的具体对象

由于 reference 类型在《Java 虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的

主流的访问方式主要有使用句柄直接指针两种

使用句柄(间接访问)

  • 如果使用句柄访问的话,Java 堆中将可能会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息,其结构如图所示:

直接指针访问

  • 如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息
  • reference 中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销,如图所示:


对象访问定位(详细)

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据操作堆上的具体对象

对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

使用句柄

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池

reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

直接指针

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

优势

这两种对象访问方式各有优势。

使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改

使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销


内存溢出和内存泄漏

内存溢出(Out of Memory,俗称 OOM)和内存泄漏(Memory Leak)是两个不同的概念,但它们都与内存管理有关。

内存溢出

①、内存溢出:是指当程序请求分配内存时,由于没有足够的内存空间满足其需求,从而触发的错误。在 Java 中,这种情况会抛出 OutOfMemoryError。

内存溢出可能是由于内存泄漏导致的,也可能是因为程序一次性尝试分配大量内存,内存直接就干崩溃了导致的。

内存泄漏

②、内存泄漏:是指程序在使用完内存后,未能释放已分配的内存空间导致这部分内存无法再被使用。随着时间的推移,内存泄漏会导致可用内存逐渐减少,最终可能导致内存溢出。

在 Java 中,内存泄漏通常发生在长期存活的对象持有短期存活对象的引用长期存活的对象又没有及时释放对短期存活对象的引用,从而导致短期存活对象无法被回收

用一个比较有味道的比喻来形容就是,内存溢出是排队去蹲坑,发现没坑了;内存泄漏,就是有人占着茅坑不拉屎,占着茅坑不拉屎的多了可能会导致坑位不够用。


内存泄漏的原因

内存泄漏可能的原因有很多种

比如说静态集合类引起内存泄漏、单例模式、数据连接、IO、Socket 等连接、变量不合理的作用域、hash 值发生变化、ThreadLocal 使用不当


静态集合类引起的内存泄漏


单例对象

单例对象会在初始化后以静态变量的方式在JVM的整个生命周期中存在,如果单例对象持有外部的引用,那么这个外部对象不能被GC回收导致内存泄露


数据连接,IO,Socket等连接

创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。


变量不合理的作用域

由于作用域原因,导致某些对象分配的内存不会马上释放


Hash值发生变化


ThreadLocal使用不当


JVM堆的内存分区

 

分为新生代和老年代

新生代:

Eden空间

Survivors空间(分为from和to两个区域)


对象什么时候进入老年代


一.长期存活的对象将进入老年代

二.大对象直接进入老年代

三.动态对象年龄判定

在Survivor空间中相同年龄的所有对象的总和大小等于Survivor空间的一半

那么年龄大于或等于该年龄的对象就可以进入老年代

四.分配担保机制

无法将对象存入Survivor空间

大多数情况下,对象在新生代中 Eden 区分配。

当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC

下面我们来进行实际测试一下


Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 分配担保机制新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。

执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存


什么是Stop The World?什么是Oop Map?什么是安全点? 

什么是Stop The World

进行垃圾回收的过程中,会涉及对象的移动

为了保证对象引用更新的正确性,必须暂停所有的用户线程

像这样的停顿,虚拟机设计者形象描述为Stop The World

简称为 STW


什么是Oop Map

Oop Map是面向对象编程映射表

它是 JVM中用于高效垃圾回收(GC)内存管理的关键数据结构

它记录了对象内部所有引用字段(指针)的位置,使垃圾回收器能够快速定位并遍历对象中的引用,确保回收的准确性


什么是安全点

什么是安全点:JVM可以安全地暂停所有应用线程以执行特定操作

这些特定的位置主要在:

  • 1.循环的末尾(非 counted 循环)
  • 2.方法临返回前 / 调用方法的 call 指令后
  • 3.可能抛异常的位置

这些位置就叫作安全点(safepoint)

用户程序执行时并非在代码指令流的任意位置都能够在停顿下来开始垃圾收集

而是必须是执行到安全点才能够暂停

用通俗的比喻,假如老王去拉车,车上东西很重,老王累的汗流浃背,但是老王不能在上坡或者下坡休息,只能在平地上停下来擦擦汗,喝口水。


对象一定分配在堆中吗?有没有了解逃逸分析技术

在 Java 中,并不是所有对象都严格在堆上分配内存,虽然堆(Heap)是 Java 对象内存分配的主要区域。

在某些情况下,JVM 的即时编译器(JIT)可能会将对象分配在栈上,这被称为逃逸分析(Escape Analysis)。

也就是说,如果编译器确定一个对象不会在方法外部使用(即对象不会逃逸出方法的作用域),那么该对象可以分配在上,而不是


什么是逃逸分析

逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。

当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)

通俗点讲,当一个对象被 new 出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸

除此之外,如果对象还有可能被外部线程访问到,例如赋值给可以在其它线程中访问的实例变量,这种就被称为线程逃逸


逃逸分析有什么好处

栈上分配

如果确定一个对象不会逃逸到线程之外,那么久可以考虑将这个对象在栈上分配,对象占用的内存随着栈帧出栈而销毁,这样一来,垃圾收集的压力就降低很多。

同步消除

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉

标量替换

如果一个数据是基本数据类型,不可拆分,它就被称之为标量。

把一个 Java 对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。

假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么可以不创建对象,直接用创建若干个成员变量代替,可以让对象的成员变量在栈上分配和读写。


网站公告

今日签到

点亮在社区的每一天
去签到