Java 类加载机制与内存区域

发布于:2024-06-14 ⋅ 阅读:(133) ⋅ 点赞:(0)

计算机组成原理

1

类加载机制与内存区域

常量池:https://zhuanlan.zhihu.com/p/206852587
方法区、永久代、元数据区的关系:https://zhuanlan.zhihu.com/p/111370230
JVM内存区域图:https://blog.csdn.net/Hao_JunJie/article/details/109764499
JVM的内存分区:https://zhuanlan.zhihu.com/p/111370230
字符串常量池:https://blog.csdn.net/weixin_42754779/article/details/129003816
字符串常量池:https://blog.csdn.net/qq_30999361/article/details/124727031
类加载机制与内存区域:https://blog.csdn.net/weixin_44424668/article/details/104201228

一、引言:常量池

1、JVM 内存结构图

1

2、常量池的分类

    Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池。

3、静态常量池

    静态常量池存放在编译时生成的class文件的常量池表中。编译器在编译Java源代码时,会将其中的字面量和符号引用存放在静态常量池中。
    class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。这部分内容将在类加载后进入运行时常量池中存放。 ① 字面量就是我们所说的常量概念,如文本字符串(加载后放到字符串常量池)、被声明为final的常量值等。 ② 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

   静态常量池就是上面说的class文件中的常量池。class常量池是在编译时每个class文件中都存在。不同的符号信息放置在不同标志的常量表中。
1

衔接-静态常量池载入到内存后如何分配

   静态常量池中内容可以分为3大点:1为包含类的版本、字段、方法、接口等描述信息,其在载入后会分配到方法区(但非运行时常量池)中;2为符号引用,其在载入后放入运行时常量池,且可以被解析为直接引用;3为字面量,字面量中又分为(参考下图)

4、运行常量池

   运行时常量池,是JVM虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中(即当.class文件被加载后静态常量池就变成了运行时常量池),每个class都有一个运行时常量池。我们常说的常量池,就是指方法区中的运行时常量池。
   Java运行时常量池是Java虚拟机在运行时动态分配的一块内存区域,用于存储编译期间生成的各种字面量(如字符串、数字和类、接口的全限定名等)和符号引用(如类和接口的符号引用、字段和方法的符号引用等),符号引用在运行时常量池可以被解析为直接引用。它们都是在编译期间确定的,在运行时被存储在常量池中供程序使用。
   运行时常量池相对于class文件常量池的另外一个特性是具备动态性,java语言并不要求常量一定只有编译期才产生,它的字面量可以动态的添加(String#intern())。

   JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。
   在解析阶段,会把符号引用替换为直接引用,比如说类的静态方法或私有方法,实例构造方法,父类方法,这是因为这些方法不能被重写其他版本,所以能在加载的时候就可以将符号引用转变为直接引用,而其他的一些方法是在这个方法被第一次调用的时候才会将符号引用转变为直接引用的。 解析的过程还会去查询字符串常量池,也就是我们下面所说的StringTable,以保证运行时常量池所引用的字符串与字符串常量池中是一致的。

5、字符串常量池

   字符串池里的内容是在类加载完成,经过验证、准备阶段之后存放在字符串常量池中。
   字符串常量池的处理机制我们前面文章已经讲到,只会存储一份,被所有的类共享。基本流程是:当程序中出现字符串常量时,如果该字符串在常量池中已存在,则直接返回常量池中的引用;否则,在常量池中创建该字符串并返回引用。这样可以避免在堆内存中重复创建相同的字符串,节省内存空间。
   在Java中,字符串常量池有两种类型:字面量和调用intern()方法的字符串。
① 字面量指的是直接使用双引号定义的字符串常量,例如:“hello”。这些字符串常量会在编译期间就被放入常量池中。
② 调用intern()方法的字符串是在运行期间动态创建的字符串,例如:String s = new String(“hello”).intern()。这些字符串会在运行时被加入常量池中。
   字符串常量池随着JDK版本的演化所在的位置也在不断的变化,字符串常量池在JDK1.7后由在方法区分配内存改为在堆中分配内存。

1.字符串常量池在Java内存区域的哪个位置?
   在JDK6.0及之前版本,字符串常量池是放在Perm Gen区(也就是方法区)中;
   在JDK7.0版本,字符串常量池被移到了堆中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。
2.字符串常量池是什么?
   在HotSpot VM里实现string pool功能的是一个StringTable类,它是一个Hash表,默认值大小长度是1009;这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。字符串常量由一个一个字符组成,放在了StringTable上。
   在JDK6.0中,StringTable的长度是固定的,长度就是1009,因此如果放入String Pool中的String非常多,就会造成hash冲突,导致链表过长,当调用String#intern()时会需要到链表上一个一个找,从而导致性能大幅度下降;
【// intern() 方法返回常量池地址】
   在JDK7.0中,StringTable的长度可以通过参数指定:-XX:StringTableSize=66666
3.字符串常量池里放的是什么?
   在JDK6.0及之前版本中,String Pool里放的都是字符串常量;
   在JDK7.0中,由于String#intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用。
   需要说明的是:字符串常量池中的字符串只存在一份!如:
String s1 = “hello,world!”;
String s2 = “hello,world!”;
   即执行完第一行代码后,常量池中已存在 “hello,world!”,那么 s2不会在常量池中申请新的空间,而是直接把已存在的字符串内存地址返回给s2。

6、JVM内存区域

(1)JVM的内存分区

下图展示了源码经过编译、加载后最终这些信息在JVM运行后是如何进行分类存储的。根据各种数据的特性JVM从逻辑上把内存划分成了几个区域;分别为方法区、虚拟机栈、本地方法栈、程序计数器、堆 5个区域。
1

  • 程序计数器是jvm执行程序的流水线,存放一些跳转指令。

  • 本地方法栈(Native Method Stack)是一种用于存储本地方法的内存区域。本地方法是指本地语言(如 C 或 C++)编写方法,可以通过 Java Native Interface(JNI)调用。or 本地方法栈和java虚拟机栈类似,区别是:java虚拟机栈是为执行java方法(字节码)服务的,而本地方法栈是为虚拟机用到的native方法服务的。在HotSpot虚拟机中,java虚拟机栈和本地方法栈合二为一。

  • 虚拟机栈是jvm执行java代码所使用的栈。

  • 方法区存放了一些常量、静态变量、类信息等,可以理解成class文件在内存中的存放位置。

  • 虚拟机堆是jvm执行java代码所使用的堆。

(2)JVM内存区域(详)[JDK1.7以前]

(各版本中方法区、虚拟机栈、本地方法栈、程序计数器、堆5个区域的位置并无变化,只是常量池随着版本的不同而进行内存位置演化)
下图是在JDK1.7之前JVM内存区域(包含详细信息)示例图:
1

  • JDK1.7之前,运行时常量池(字符串常量池也在里边)是存放在方法区,此时方法区的实现是永久带。
  • JDK1.7字符串常量池被单独从方法区移到堆中,运行时常量池剩下的还在永久带(方法区)
  • JDK1.8,永久带更名为元空间(方法区的新的实现),但字符串常量池还在堆中,运行时常量池在元空间(方法区)。
(3)JVM常量池内存位置演化

引言:方法区、永久代、元数据区的关系
方法区是JVM 定义的一种规范,是所有虚拟机都需要遵守的约定, 而“永久代(PermGen space)”和“元数据(MetaSpace)”都是实际某个虚拟机针对“方法区”的一种实现,“永久代”是JDK1.7之前Hotspot虚拟机对方法区的实现,而“元数据”则是1.8之后Hotspot虚拟机针对方法区的一种实现而已。

  • 在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
    1
    1

  • 在JDK1.7字符串常量池和静态变量被从方法区拿到了堆中,运行时常量池剩下的还在方法区, 也就是hotspot中的永久代
    2

  • 在JDK8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆,运行时常量池还在方法区,只不过方法区的实现从永久代变成了元空间(Metaspace) —存疑???静态变量这部分
    33

  • 直接内存(非程序运行时数据区的一部分,即本地内存不是运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域,但是这部分内存也被频繁使用)

二、初识JVM

    我们写好一份Java代码,要将其部署到线上的机器去运行,就要将其打包成.jar.war后缀的包,再进行部署。其中关键的一步是编译,也就是要把.java文件编译成.class字节码文件,有了字节码文件可以通过java命令来启动一个JVM进程,由JVM来负责运行这些字节码文件。所以说,在某个机器上部署某个系统后,一旦启动这个系统,实际上就是启动了JVM

jar 文件就是 Java Archive File,顾名思意,它的应用是与 Java 息息相关的,是 Java 的一种文档格式(也可以说是一种Java的压缩文件);jar文件是可以由JVM直接执行的文件,只要操作系统安装了JVM便可以运行作为Java应用程序的jar文件。其通常包含了Java的类文件,资源文件,元数据文件等

   我们写好了一个个类是通过类加载器把字节码文件加载到JVM中的,JVM会首先从main()方法开始执行里面的代码,它需要哪个类就会使用类加载器来加载对应的类,反正对应的类就在.class文件中。

注意: 如果一个项目中有多个main()方法,在启动一个jar包的时候,就制定了是走哪个main()方法,所以入口是唯一的。

1

三、初识JVM类加载器机制

1、引入

    问题:JVM什么时候会加载一个类?
    最简单的例子是直接从main()进入开始执行,比如

public class Kafka {
    public static void main(String[] args) {
    }
}

1
    如果碰到了实例化对象的操作,才把实例化的这个类的.class文件加载到内存(之前是没有加载进来的)

public class Kafka {
    public static void main(String[] args) {
        ReplicaManager replicaManager = new ReplicaManager();
    }
}

1    首先是包含main()方法的主类会在JVM启动之后首先被加载到内存中,然后开始执行main()中的代码,碰到需要使用的类,才去加载这个类对应的字节码文件,也就是说是按需加载。

2、类加载过程

在 Java 中,类的加载主要分为以下三个步骤:

  • 加载(Loading):查找并加载类的二进制数据。这一步是类加载过程的第一个阶段,它的主要任务是通过一个类的全限定名来获取定义此类的二进制字节流。

  • 连接(Linking):执行以下步骤:

    a. 验证(Verification):确保被加载的类的正确性,包括文件格式的验证、元数据的验证、字节码的验证等。

    b. 准备(Preparation):为类的静态变量分配内存并设置初始值(默认值)。

    c. 解析(Resolution):将符号引用转换为直接引用,这个阶段在1.8版本之前是可选的,而在1.8版本之后,类加载的时候,必须对符号引用进行解析,这是因为1.8版本中加入了元空间(Metaspace)这个概念,而元空间是使用直接内存实现的,因此需要进行解析。

  • 初始化(Initialization):执行类的初始化,包括执行类构造器 () 方法(“()并不是程序员在Java代码中直接编写的方法,而是Javac编译器的自动生成物)的过程。在这个阶段,才真正开始执行类中定义的 Java 程序代码。

    需要注意的是,类的加载过程是一种深度优先的过程,即先加载依赖的类,再加载本身。如果在加载的过程中出现错误,就会抛出 ClassNotFoundException 或 NoClassDefFoundError 异常。

  • 什么时候会初始化一个类呢?
        一般来说包含main()方法的类是必须立马初始化的,或者说执行到new对象了,就会把这个对象的类初始化,又或者使用类的静态成员(静态属性,静态方法)时,类也会被初始化。如果这个类初始化过了,就不用进行第二次初始化。初始化重要的一个规则是:初始化一个类的时候,如果该类的父类没有初始化,(如果父类也没有加载的话)必须先加载并初始化它的父类!

3、类加载器和双亲委派机制

   类的加载过程需要涉及到类加载器,JVM在运行的时候会产生三个类加载器,这三个类加载器组成了一个层级关系。每一个类加载器分别去加载不同作用范围的jar包

1

1、Java中的类加载器

  • 启动类加载器:负责加载机器上安装的Java目录下的核心类,Java安装目录下有个lib文件存放了Java的核心库,JVM启动后,首先会依托启动类加载器去加载lib
  • 扩展类加载器:就是加载lib/ext目录,和启动类加载器差不多,但它是启动类加载器的儿子
  • 应用程序类加载器:负责加载ClassPath环境变量指定路径中的类,就是把你写好的代码加载进内存
  • 自定义类加载器:自己写的类加载器,继承ClassLoader类,重写类加载方法

2、双亲委派机制

    JVM的加载器是有亲子结构的,如图所示,提出了双亲委派机制

双亲委派机制:如果应用程序要加载一个类,首先会委派自己的父类加载器去加载,直至传到最顶层的加载器去加载,如果父类加载器在自己的职责范围内没有找到这个类,就会把加载权利下放给子类加载器。总的来说,就是先找父类去加载,不行再由儿子来加载。先从顶层加载器开始,发现自己加载不到,往下推给子类,这样能保证绝不会重复加载某个类

即,双亲委派的好处:避免了类的重复加载,如果两个不同层级的类加载器可以加载同一个类,就重复了。

Kim解释
假若在没有双亲委派机制的情况下,我们自己写了一个java.lang.String.class类(验证了一下自己按Java核心类库的某个类一模一样的创建包及类也不会出错),这时启动类加载器和应用程序加载器都会去加载这个类,但由于JVM规范:每个类加载器都有属于自己的命名空间,故JVM会认为这两个加载器加载出来的类并不是同一个类,这时就会产生歧义。双亲委派机制会使得当父亲已经加载了该类时,儿子就没有必要再加载一次,保证被加载类的唯一性。那么又为什么是向上委派,而不是向下委派呢?这是因为保证了沙箱安全机制:即,越核心的类库越被上层的类加载器加载,而某限定名的类一旦被加载过了,被动情况下,就不会再加载相同限定名的类。如,自己写的java.lang.String.class类就不会再被加载,这样便可以防止核心API库被随意篡改

1
2

四、初识JVM内存区域

   我们写好的代码中有很多的类,类中有许多的方法,同时方法中也有许多变量,它们都需要放到合适的区域,这就是JVM为什么要划分出不同内存区域的原因,下面介绍下JVM中的内存区域分类。

1、存放类的区域——方法区

   方法区主要存放从class文件中加载进来的类,JDK 1.8后这块区域改名为Metaspace,即元数据空间,放的还是我们自己写的各种类相关的信息。

public class Kafka {
    public static void main(String[] args) {
        ReplicaManager replicaManager = new ReplicaManager();
    }
}

   假设我们有上面这个例子,JVM首先类加载Kafka.class到方法区,当程序运行到实例化对象那句,就把ReplicaManager.class加载到方法区。如果Kafka类中有静态变量,也一样会进入方法区


网站公告

今日签到

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