# Java内存区域

Java内存区域

JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区域【JAVA 堆、方法区】、直接内存。

  • 线程私有数据区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁(在Hotspot VM 内。
  • 线程共享区域随虚拟机的启动/关闭而创建/销毁。
  • 直接内存并不是JVM运行时数据区的一部分, 但也会被频繁的使用:

# 程序计数器(线程私有)

一块较小的内存空间, 是当前线程所执行的字节码的行号指示器,每条线程都要有一个独立的程序计数器,这类内存也称为“线程私有”的内存。 正在执行java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址)。如果还是Native 方法,则为空。

# 虚拟机栈(线程私有)

是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

# 本地方法区(线程私有)

本地方法区和Java Stack 作用类似, 区别是虚拟机栈为执行Java 方法服务, 而本地方法栈则为Native 方法服务,

# 堆(线程共享)

是被线程共享的一块内存区域,创建的对象和数组都保存在Java 堆内存中,也是垃圾收集器进行垃圾收集的最重要的内存区域。由于现代VM 采用分代收集算法, 因此Java 堆从GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和To Survivor 区)和老年代。

Java 堆从GC 的角度还可以细分为: 新生代(Eden 区、From Survivor 区和To Survivor 区)和老年代。

  • 新生代:是用来存放新生的对象。一般占据堆的1/3 空间。由于频繁创建对象,所以新生代会频繁触发MinorGC 进行垃圾回收。新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。

    • Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden 区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。

    • ServivorFrom:ServivorFrom上一次GC 的幸存者,作为这一次GC 的被扫描者。

    • ServivorTo:保留了一次MinorGC 过程中的幸存者。

  • 老年代主要存放应用程序中生命周期长的内存对象。 老年代的对象比较稳定,所以MajorGC 不会频繁执行。

# 方法区(线程共享,永久代)

即我们常说的永久代(Permanent Generation), 用于存储被JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据. HotSpot VM把GC分代收集扩展至方法区, 即使用Java堆的永久代来实现方法区, 这样HotSpot 的垃圾收集器就可以像管理Java 堆一样管理这部分内存,而不必为方法区开发专门的内存管理器(永久带的内存回收的主要目标是针对常量池的回收和类型的卸载, 因此收益一般小)。

运行时常量池(Runtime Constant Pool) 是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。 Java 虚拟机对Class 文件的每一部分(自然也包括常量池)的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求,这样才会被虚拟机认可、装载和执行。

# 内存区域总结

JVM 内存区域主要分为线程私有区域【程序计数器、虚拟机栈、本地方法区】、线程共享区域【JAVA 堆、方法区】、直接内存。

  • 程序计数器:较小的内存空间,是当前线程所执行的字节码的行号指示器。
  • 虚拟机栈:是描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 堆(分为新生代和永久代):最大的一块内存区域,用于存储创建的对象和数组
  • 方法区:用于存储被JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据.

# JVM的垃圾回收机制

垃圾回收

# 什么是GC

垃圾回收机制,内存空间是有限的,你创建的每个对象和变量都会占据内存,gc做的就是对象清除将内存释放出来,这就是GC要做的事。

# 哪些内存需要回收

JVM的内存结构包括五大区域:程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则不一样、不一样!(怎么不一样说的朗朗上口),这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

垃圾收集器在对堆区和方法区进行回收前,首先要确定这些区域的对象哪些可以被回收,哪些暂时还不能回收,这就要用到判断对象是否存活的算法!

# 确定垃圾的算法

  • 引用计数法:是一种比较古老的回收算法。原理是此对象有一个引用,即增加一个计数,删除一个引用则减少一个计数。垃圾回收时,只需要收集计数为0的对象。此算法最致命的是无法处理循环引用的问题。

  • 可达性分析:基本思路就是通过一系列可以做为root的对象作为起始点,从这些节点开始向下搜索。当一个对象到root节点没有任何引用链接时,则证明此对象是可以被回收的。以下对象会被认为是root对象。

    在Java语言中,可作为GC Roots的对象包括下面几种:

    a) 虚拟机栈中引用的对象(栈帧中的本地变量表);

    b) 方法区中类静态属性引用的对象;

    c) 方法区中常量引用的对象;

    d) 本地方法栈中JNI(Native方法)引用的对象。

# 垃圾回收算法

​ HotSpot 虚拟机采用了可达性分析来进行内存回收,常见的回收算法有标记-清除算法,复制算法和标记整理算法。

  • 标记清除算法(Mark-Sweep ):最基础的垃圾回收算法,标记-清除算法采用从根集合(GC Roots)进行扫描,对存活的对象进行标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收。该算法最大的问题是内存碎片化严重,后续可能发生大对象不能找到可利用空间的问题。
  • 复制算法(copying):复制算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。复制算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。当然,此算法的缺点也是很明显的,就是需要两倍内存空间。
  • 标记整理算法(Mark-Compact):标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
  • 分代收集法:分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),在堆区之外还有一个代就是永久代(Permanet Generation)。老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

# 年轻代(Young Generation)的回收算法

a) 所有新生成的对象首先都是放在年轻代的。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。

b) 新生代内存按照8:1:1的比例分为一个eden区和两个survivor(survivor0,survivor1)区。一个Eden区,两个 Survivor区(一般而言)。大部分对象在Eden区中生成。回收时先将eden区存活对象复制到一个survivor0区,然后清空eden区,当这个survivor0区也存放满了时,则将eden区和survivor0区存活对象复制到另一个survivor1区,然后清空eden和这个survivor0区,此时survivor0区是空的,然后将survivor0区和survivor1区交换,即保持survivor1区为空, 如此往复。

c) 当survivor1区不足以存放 eden和survivor0的存活对象时,就将存活对象直接存放到老年代。若是老年代也满了就会触发一次Full GC(Major GC),也就是新生代、老年代都进行回收。

d) 新生代发生的GC也叫做Minor GC,MinorGC发生频率比较高(不一定等Eden区满了才触发)。

# 年老代(Old Generation)的回收算法

a) 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。

b) 内存比新生代也大很多(大概比例是1:2),当老年代内存满时触发Major GC即Full GC,Full GC发生频率比较低,老年代对象存活时间比较长,存活率标记高。

# 永久代(Permanent Generation)的回收算法

用于存放静态文件,如Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。永久代也称方法区。

# Java四种引用

强引用:在Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM也不会回收。因此强引用是造成Java 内存泄漏的主要原因之一。

软引用:软引用需要用SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。

弱引用:弱引用需要用WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管JVM 的内存空间是否足够,总会回收该对象占用的内存。

虚引用:虚引用需要PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。

# JVM类加载机制

# 类的加载

虚拟机加载Class文件(二进制字节流)到内存,并对数据进行校验、转换解析和初始化,最终形成可被虚拟机直接使用的Java类型,这一系列过程就是类的加载机制。

类从被虚拟机加载到内存开始,直到卸载出内存为止,整个生命周期包括:加载——验证——准备——解析——初始化——使用——卸载 这7个阶段。其中验证、准备、解析3个部分统称为连接。生命周期图如下:

JVM类加载机制

# 类加载条件

一般我们的一个程序中会有很多 class 文件,那 jvm 会无条件加载这些文件吗?

肯定不是的,其实 jvm 只有在**“使用”该 class 文件时才会加载,这里的“使用”主动使用**,主动使用只有下列几种情况:

1.当创建一个类的实例时,比如使用 new 关键字或者反射、克隆、反序列化

2.当调用类的静态方法时,即使用字节码 invodestatic 指令

3.当使用类或接口的静态字段时(final 常量除外),比如使用 getstatic 或者 putstatic 指令

4.当使用 java.lang.reflect 包中的方法反射类的方法时

5.当初始化子类时,要求先初始化父类

6.作为启动虚拟机,含有 main() 方法的那个类

除上面列出的 6 点为主动使用外,其他都是被动使用

# 类加载的过程详解

(一)装载:查找并加载类的二进制数据(查找和导入Class文件)

加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:

1、通过一个类的全限定名来获取其定义的二进制字节流。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

(二)链接(分3个步骤)

1、验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段是非常重要的,但不是必须的。它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

2、准备:为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

3、解析:把类中的符号引用转换为直接引用

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

(三)初始化:对类的静态变量,静态代码块执行初始化操作

初始化为类的静态变量赋予正确的初始值。JVM负责对类进行初始化,主要对类变量进行初始化。在Java中对类变量进行初始值设定有两种方式:声明类变量是指定初始值、使用静态代码块为类变量指定初始值。

# 类加载器的类型

jvm 会创建三种类加载器,分别为启动类加载器、扩展类加载器和应用类加载器,下面我们分别简单介绍下各个类加载器。

  • 启动类加载器 Bootstrap ClassLoader 主要负责加载系统的核心类,如 :rt.jar 中的 java 类,我们在 Linux 系统或 Windows 系统使用 java,都会安装 jdk,lib 目录里其实里面就有这些核心类。
  • 扩展类加载器 Extension ClassLoader 主要用于加载 lib\ext 中的 java 类,这些类会支持系统的运行。
  • 应用类加载器 Application ClassLoader 主要加载用户类,即加载用户类路径(ClassPath)上指定的类库,一般都是我们自己写的代码。

# 双亲委派模型

在类加载时,系统会判断当前类是否已经加载,如果已经加载了,就直接返回可用的类,否则就会尝试去加载这个类。在尝试加载类时,这个收到了类加载的请求的类加载器会先委派给其父加载器加载,最终传到顶层的加载器加载。如果父类加载器在自己的负责的范围内没有找到这个类,就会下推给子类加载器加载。加载情况如下所示:

双亲委派模型

可见检查类是否加载的委派过程是单向的,底层的类加载器询问了半天,到最后还是自己加载类,那不白费力气了吗?这样做当然有它的好的,这样在结构上比较清晰,最重要的是可以避免多层级的加载器重复加载某些类

双亲委派模型检查类加载是单向的,但这样也有个弊端就是上层的类加载器无法访问由下层类加载器所加载的类。 那如果启动类加载器加载的系统类中提供了一个接口,接口需要在应用中实现,还绑定了一个工厂方法,用于创建该接口的实例。而接口和工厂方法都在启动类加载器中。这时就会出现该工厂无法创建由应用类加载器加载的应用实例的问题。 比如 JDBC、XML Parser 等。

LastUpdated: 3/13/2021, 11:15:56 PM