目录
前言:
一.内存动态分配
1.运行时数据区
1.1堆(Heap)
1.2方法区(Method Area)
1.3虚拟机栈(Java Virtual Machine Stack)
1.4本地方法栈(Native Method Stacks)
1.5程序计数器(Program Counter Register)
1.6运行时常量池(Runtime Constant Pool)
1.7直接内存(Direct Memory)
2.对象与内存分配
2.1对象的创建流程(怎么来)
2.2对象的内存分配(放在哪)
2.2.1指针碰撞(Bump The Pointer)
2.2.2空闲列表(Free List)
2.3对象的内存布局(长啥样)
2.4对象的访问定位(怎么找)
二.垃圾回收机制
1.找到需要清理的对象
1.1判断对象是否存活
1.2GC Root对象说明
1.3引用类型
1.4不推荐使用的finalize()
2.回收清理对象
2.1分代理论下的堆内存划分
2.2不同的GC分类
2.3GC流程概览
2.4垃圾收集算法
2.4.1标记-清除算法
2.4.2标记-复制算法
2.4.3标记-整理算法
上文《聊聊JVM——类加载机制》之后,在我们眼前的便是那堵由内存动态分配和垃圾收集技术所围成的高墙了——java虚拟机自动内存管理机制。
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:
Java程序在运行时,会为JVM单独划出一块内存区域,而这块内存区域又可以再次划分出一块运行时数据区,运行时数据区域大致可以分为五个部分:堆、方法区、虚拟机栈、本地方法栈和程序计数器。其中堆和方法区是所有线程共享的,而程序计数器、虚拟机栈和本地方法栈是线程私有的。
Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创 建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。 Java世界中“几乎”所有的对象都在堆中分配,但是,随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从jdk 1.7开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用 (也就是未逃逸出去),那么对象可以直接在栈上分配内存。
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态 变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。方法区也被成为永久代,是有区别的:
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那 么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类 的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久 代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。
说到方法区,那么JDK1.6、1.7、1.8的内存区域变化有必要了解一下。主要体现在方法区实现上的差异:
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
客观原因:使⽤永久代来实现⽅法区的决定的设计导致了Java应⽤更容易遇到内存溢出的问题(永久代有-XX: MaxPermSize的上限,即使不设置也有默认大小,⽽J9和JRockit 只要没有触碰到进程可⽤内存的上限,例 如32位系统中的4GB限制,就不会出问题),⽽且有极少数⽅法 (例如 String::intern())会因永久代的原因⽽导致不同虚拟机下有不同的表现。
主观原因:在JDK8,合并HotSpot和JRockit的代码时,JRockit从来没有一个叫永久代的东西, 合并之后就没 有必要额外的设置这么一个永久代的地方了。
虚拟机栈的生命周期和线程相同,描述的是java方法执行的线程内存模型:方法执行时,JVM会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态链接、方法出口和一些辅助信息。
Main()方法是调用链的起始位置,但不一定在栈帧的底部。在main
方法内部,可能会调用其他方法,这些方法会在main
方法的栈帧之上入栈。
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现StackOverFlowError和OutOfMemoryError两种错误。
也被成为PC寄存器,是一块较小的内存空间,程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创 建而创建,随着线程的结束而死亡。主要有两个作用:
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。
JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区 (Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
JVM 中对象的创建,我们从⼀个new指令开始:
对象的内存分配有两种⽅式,指针碰撞(Bump The Pointer)、空闲列表(Free List)。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;而当使用CMS这种基于清除 (Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那 个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump The Pointer)。
指针碰撞方案会涉及到一个多线程分配对象导致堆抢占的线程不安全问题。
线程1分配A对象还没完成指针的修改,线程2进行B对象分配内存发生抢占。常用的保证并发安全解决方案:
线程本地分配缓冲(Thread-Local Allocation Buffer,TLAB):
CAS(Compare-And-Swap):
但如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那 就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分 配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称 为“空闲列表”(Free List)。
放在哪块列表中和JVM分配策略有关,空闲列表的内存分配策略:
首次适应(First Fit):
最佳适应(Best Fit):
最差适应(Worst Fit):
分代策略:
TLAB(Thread-Local Allocation Buffer):
在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)。
对象头主要由两部分组成:
实例数据⽤来存储对象真正的有效信息,也就是我们在程序代码⾥所定义的各种类型的字段内容,⽆论是从⽗类继承的,还是⾃⼰定义的。
对⻬填充不是必须的,没有特别含义,仅仅起着占位符的作⽤。
本地打印观察下:
创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具 体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义 这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄和直接指针两种:
两种对象访问⽅式各有优势,使⽤句柄来访问的最⼤好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是⾮常普遍的⾏为)时只会改变句柄中的实例数据指针,⽽reference本身不需要被修改。 使⽤直接指针来访问最⼤的好处就是速度更快,它节省了⼀次指针定位的时间开销,由于对象访问在Java中⾮常频繁,因此这类开销积少成多也是⼀项极为可观的执⾏成本。HotSpot 虚拟机主要使⽤直接指针来进⾏对象访问。
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
还有一部分是某个时刻成为GC Root,但很快就会失去这个角色,从而允许被垃圾回收的临时GC Root对象,这些临时的GC Root 对象包括:
Java虚拟机(JVM)中有四种不同的引用类型,它们用于描述对象的可达性和生命周期,对垃圾回收器的对象回收行为产生影响。这四种引用类型分别是:
java.lang.ref.SoftReference
类来实现。OutOfMemoryError
错误。java.lang.ref.WeakReference
类来实现。java.lang.ref.PhantomReference
类来实现。
finalize()方法是Java中的一个特殊方法,它用于对象的垃圾回收前的清理和资源释放。finalize()方法属于java.lang.Object
类,因此所有Java类都可以重写这个方法以定义自己的垃圾回收前的清理行为。
finalize()方法的基本工作原理如下:
当垃圾回收器确定一个对象不再可达时,它会将该对象标记为“待回收”状态,并将在稍后的某个时间点调用该对象的finalize()方法。
在对象的finalize()方法中,开发人员可以编写清理代码,例如关闭文件、释放资源、断开网络连接等。
一旦finalize()方法被调用,对象就会被标记为已经执行过finalize(),并在下一次垃圾回收时被彻底回收。
需要注意的是,finalize()方法存在一些问题和限制:
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分 代假说之上:1)弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。2)强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡。这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。由于对象之间会存在跨代引用,分代收集理论添加了第三条经验法则:3)跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden 区->Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 - XX:MaxTenuringThreshold 来设置。
·部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
■新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
■老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
■混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
·整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
过程中需要注意对象进入老年代的场景:
-XX:MaxTenuringThreshold
进行配置,默认为 15 次 Minor GC。算法分为“标记”和“清除”两个阶段:首先标记出所有需要回 收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内 存