JVM学习

jvm学习笔记

1、Java代码是怎么运行的

一个jdk包括(java工具包括(javac编译器和jar打包工具)和 jre(jvm虚拟机和java核心类库))

一部分是 Java 工具包:javac、jar、javadoc等。

  • javac:编译器,将源程序 .java 编译成字节码 .class 文件;
  • jar:打包工具,将相关的类文件或第三方jar包打包成一个 jar 文件;
  • javadoc:文档生成器,从源码中将符合 javadoc 规范的注释提取文档;

还有一部分是 Java 程序运行的标准环境 JRE(Java Runtime Environment):JVM、Java核心类库。

  • JVM:Java 虚拟机,编译后的 .class 字节码文件的运行平台。
  • Java核心类库:Java SE API子集(包括 java.lang包、java.io包、java.util包等)下的所有类。

生命周期:

编译、打包、类加载、创建对象、方法调用、多线程上下文切换、类卸载

  • 编译:通过 javac 编译工具将 .java 的源程序文件编译为 .class 的字节码文件,因为 JVM 只认识 .class 字节码。Java 程序的跨平台能力(Write once, Run Anywhere)也是在这里,因为不同平台的 JVM 对底层操作系统的实现上不同,但是对输入的内容要求都是 .class 字节码文件,这也就是我们编译后的 .class 文件可以在不同平台的 JVM 上正常运行的原因所在。
  • 打包:一个完整的项目不可能只是一个 .class 文件就可以完成的,需要多个 .class 文件,可能还需要依赖多个外部的第三方 jar 包(Java有一套完整的应用程序接口,还有无数来自商业机构和开源社区的第三方类库来帮助你实现各种各样的功能),但是一个项目的入口文件只有一个,这时就需要把多个 .class 文件、多个 jar 包等打包为一个 jar 或 war 包,并指定入口文件地址。这里用到的打包工具就是JDK自带的 jar 工具了。
  • 类加载:JVM找到项目的入口文件,启动应用程序,并通过项目运行过程中的相关依赖加载其他 .class 文件的过程就是类加载。加载后的 .class 文件保存在JVM的方法区,是所有线程共享的区域,在加载的过程中会初始化类常量,在JVM的堆内存中保存对应加载类的class对象相关信息等。
  • 创建对象:Java是面向对象的程序设计语言,程序的运行也是以对象为调用单位,在运行的过程中需要通过加载在方法区的 .class 字节码文件创建与其对应的对象信息。创建对象的方式有:new关键字,反射等,创建后的对象信息保存在JVM的堆内存中,是所有线程共享的区域,也是垃圾回收的主要区域(JVM提供了一个相对安全的内存管理和访问机制,避免了绝大部分的内存泄露和指针越界的问题)。
  • 方法调用:JVM的调用单位是对象,但是真正执行功能性的代码却是对象上的方法,当调用某个对象的某个方法时,JVM执行引擎会将对应方法的 .class 字节码文件通过解释执行或即时编译(执行引擎实现了热点代码检测和运行时编译及优化,这使得Java应用能随着运行时间的增加而获得更高的性能)的方式再次编译为计算机所能识别的机器码(.class字节码是JVM所能识别的文件,计算机能识别的只有机器码),编译后的机器码信息存储在方法区内存中。对该方法生成一个方法帧,根据是否是c++ 实现的native方法压栈到本地方法栈或Java方法栈,方法调用完成再出栈结束调用。方法栈内存是线程私有的,每个线程都有自己的方法栈。
  • 多线程上下文切换:Java是一门多线程编程语言,在应用运行的过程中CPU会进行不同线程间的切换调度,如果当前线程还在运行中,这时CPU告诉它“你运行的时间结束了,换下一个”,但是已经运行过的结果信息是需要保存的,不然这之前的工作岂不是白做了。这时就需要将当前要结束的线程的上下文信息保存起来,保存到JVM的PC寄存器(程序计数器)中,这块的内存是当前线程私有的,不会被其他线程修改。当下次CPU再通知当前线程说“别等了,该你执行了”的时候,当前线程只需要把PC寄存器中的数据恢复,接着上一次执行到的位置继续执行就好了。
  • 类卸载:当堆内存中的某个对象执行完成并且没有其他可运行的对象在引用它时,这个对象就会被垃圾回收线程从堆内存中删除,并且释放掉该对象所占用的内存空间。被垃圾回收掉的对象对应的方法区 .class 字节码文件也需要被删除掉,这就是类卸载。

2、java的基本数据类型

  • 基本数据类型:主要用来支持数值计算,因为使用基本类型能够在执行效率以及内存使用上提升软件性能(只需要保存数值,不需要保存指针)。
  • 引用数据类型:可再分为数组类型、类类型、接口类型,主要用来支持面向对象的数据模型设计。

基本数据类型

类型值域默认值
boolean{false, true}false
byte[-128, 127]0
short[-32768, 32767]0
char[0, 65535]‘\u0000’
int[-2^31, 2^31 - 1]0
long[-2^63, 2^63 - 1]0L
float~[-3.4E38, 3.4E38]+0.0F
double~[-1.8E308, 1.8E308]+0.0D

Java的基本类型都有对应的值域和默认值。可以看到byte、short、int、long、float以及double的值域依次扩大,而且前面的值域被后面的值域所包含。因此:

  • 从前面的基本类型转后面的基本类型,无需强制转换:

强制转换时:小值域的值 = - (小值域的范围值 - |大值域的值|)

// 大值域的值 超出 小值域的值域范围
s = 138;
b = (byte) s; // b = -118 = - (256 - |138|)

System.out.println("b=" + b + ", s=" + s);
// b=-118, s=138

3、编译

编译是将一种抽象程度更高的语言转换为更贴近计算机能够识别的语言的过程。

在Java中存在两次编译,不过我们今天说的是其中的第一次编译,就是将 .java 源程序编译为 .class 字节码文件的过程。

编译及运行相关命令

编译 .java 文件的命令 javac 用法:

用法: javac <options> <source files>
其中, 可能的选项包括:
  -g                         生成所有调试信息
  -g:none                    不生成任何调试信息
  -g:{lines,vars,source}     只生成某些调试信息
  -nowarn                    不生成任何警告
  -verbose                   输出有关编译器正在执行的操作的消息
  -deprecation               输出使用已过时的 API 的源位置
  -classpath <路径>            指定查找用户类文件和注释处理程序的位置
  -cp <路径>                   指定查找用户类文件和注释处理程序的位置
  -sourcepath <路径>           指定查找输入源文件的位置
  -bootclasspath <路径>        覆盖引导类文件的位置
  -extdirs <目录>              覆盖所安装扩展的位置
  -endorseddirs <目录>         覆盖签名的标准路径的位置
  -proc:{none,only}          控制是否执行注释处理和/或编译。
  -processor <class1>[,<class2>,<class3>...] 要运行的注释处理程序的名称; 绕过默认的搜索进程
  -processorpath <路径>        指定查找注释处理程序的位置
  -parameters                生成元数据以用于方法参数的反射
  -d <目录>                    指定放置生成的类文件的位置
  -s <目录>                    指定放置生成的源文件的位置
  -h <目录>                    指定放置生成的本机标头文件的位置
  -implicit:{none,class}     指定是否为隐式引用文件生成类文件
  -encoding <编码>             指定源文件使用的字符编码
  -source <发行版>              提供与指定发行版的源兼容性
  -target <发行版>              生成特定 VM 版本的类文件
  -profile <配置文件>            请确保使用的 API 在指定的配置文件中可用
  -version                   版本信息
  -help                      输出标准选项的提要
  -A关键字[=]                  传递给注释处理程序的选项
  -X                         输出非标准选项的提要
  -J<标记>                     直接将 <标记> 传递给运行时系统
  -Werror                    出现警告时终止编译
  @<文件名>                     从文件读取选项和文件名

运行编译结果 .class 文件的命令 java 用法:

用法: java [-options] class [args...]
           (执行类)
   或  java [-options] -jar jarfile [args...]
           (执行 jar 文件)
其中选项包括:
    -d32	  使用 32 位数据模型 (如果可用)
    -d64	  使用 64 位数据模型 (如果可用)
    -server	  选择 "server" VM
                  默认 VM 是 server,
                  因为您是在服务器类计算机上运行。


    -cp <目录和 zip/jar 文件的类搜索路径>
    -classpath <目录和 zip/jar 文件的类搜索路径>
                   : 分隔的目录, JAR 档案
                  和 ZIP 档案列表, 用于搜索类文件。
    -D<名称>=<>
                  设置系统属性
    -verbose:[class|gc|jni]
                  启用详细输出
    -version      输出产品版本并退出
    -version:<>
                  警告: 此功能已过时, 将在
                  未来发行版中删除。
                  需要指定的版本才能运行
    -showversion  输出产品版本并继续
    -jre-restrict-search | -no-jre-restrict-search
                  警告: 此功能已过时, 将在
                  未来发行版中删除。
                  在版本搜索中包括/排除用户专用 JRE
    -? -help      输出此帮助消息
    -X            输出非标准选项的帮助
    -ea[:<packagename>...|:<classname>]
    -enableassertions[:<packagename>...|:<classname>]
                  按指定的粒度启用断言
    -da[:<packagename>...|:<classname>]
    -disableassertions[:<packagename>...|:<classname>]
                  禁用具有指定粒度的断言
    -esa | -enablesystemassertions
                  启用系统断言
    -dsa | -disablesystemassertions
                  禁用系统断言
    -agentlib:<libname>[=<选项>]
                  加载本机代理库 <libname>, 例如 -agentlib:hprof
                  另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
    -agentpath:<pathname>[=<选项>]
                  按完整路径名加载本机代理库
    -javaagent:<jarpath>[=<选项>]
                  加载 Java 编程语言代理, 请参阅 java.lang.instrument
    -splash:<imagepath>
                  使用指定的图像显示启动屏幕
有关详细信息, 请参阅 http://www.oracle.com/technetwork/java/javase/documentatio

编写测试类

/**
 * 注意这里无任何包名
 */
public class TestClass {

    public static void main(String[] args) {
        System.out.println("hello java!");
    }

}

保存文件名 TestClass.java。

编译 .java 文件

// cd 到 TestClass.java 文件所在目录
cd /xxx/xxx

// 使用 javac 命令对 TestClass.java 文件进行编译
javac TestClass.java

// 编译成功后会在当前目录下生成一个 TestClass.class 字节码文件

运行 .class 文件

// cd 到 TestClass.class 文件所在目录
cd /xxx/xxx

// 使用 java 命令运行 TestClass.class 字节码文件
// 注意这里运行的文件无后缀名,默认运行的是 .class 文件
java TestClass

// 运行成功后控制台会输出 hello java!

4、打包

打包命令

首先我们看一下打包命令 jar 的使用说明:

用法: jar {ctxui}[vfmn0PMe] [jar-file] [manifest-file] [entry-point] [-C dir] files ...
选项:
    -c  创建新档案
    -t  列出档案目录
    -x  从档案中提取指定的 (或所有) 文件
    -u  更新现有档案
    -v  在标准输出中生成详细输出
    -f  指定档案文件名
    -m  包含指定清单文件中的清单信息
    -n  创建新档案后执行 Pack200 规范化
    -e  为捆绑到可执行 jar 文件的独立应用程序
        指定应用程序入口点
    -0  仅存储; 不使用任何 ZIP 压缩
    -P  保留文件名中的前导 '/' (绝对路径)".." (父目录) 组件
    -M  不创建条目的清单文件
    -i  为指定的 jar 文件生成索引信息
    -C  更改为指定的目录并包含以下文件
如果任何文件为目录, 则对其进行递归处理。
清单文件名, 档案文件名和入口点名称的指定顺序
与 'm', 'f''e' 标记的指定顺序相同。

示例 1: 将两个类文件归档到一个名为 classes.jar 的档案中: 
       jar cvf classes.jar Foo.class Bar.class 
示例 2: 使用现有的清单文件 'mymanifest' 并
           将 foo/ 目录中的所有文件归档到 'classes.jar' : 
       jar cvfm classes.jar mymanifest -C foo/ .

编写测试代码

  • 工具类 Hello.java
package me.qbian;

public class Hello {
    public static void say(String name) {
        System.out.println("hello " + name);
    }
}
  • 入口类 Main.java
package me.qbian;

public class Main {
    public static void main(String[] args) {
        for (String arg : args) {
            System.out.println("启动参数:" + arg);
        }

        Hello.say("java");
    }
}
  • 打包配置文件 MANIFEST.MF (注意最后有一个换行)
Manifest-Version: 1.0
Created-By: 1.8.0_111 (Oracle Corporation)
Main-Class: me.qbian.Main

以上三个文件在同一文件目录下。最后在当前目录新建文件夹 classes。当前文件夹文件结构如下:

./classes
Hello.java
Main.java
MANIFEST.MF

打包测试代码

  • 使用 javac 命令编译测试代码到 classes 文件夹
// -d 指定编译后的文件所在路径
// 编译的时候会根据包名自动创建包文件夹
javac *.java -d ./classes

编译后的当前文件夹文件结构如下(注意其中的 me/qbian 文件夹都是编译的时候根据包名自动创建的):

./classes/me/qbian/Hello.class
./classes/me/qbian/Main.class
Hello.java
Main.java
MANIFEST.MF
  • 使用jar命令对已编译的.class文件打包
// 使用当前文件夹下的打包配置文件 MANIFEST.MF
// 对当前 classes 文件夹下所有文件进行递归打包
// 打包后的包名字为 test.jar
jar cvfm test.jar MANIFEST.MF -C classes/ .

打包后的当前文件夹文件结构如下(多出来了一个打包文件 test.jar):

./classes/me/qbian/Hello.class
./classes/me/qbian/Main.class
Hello.java
Main.java
MANIFEST.MF
test.jar

运行打包文件

  • 不带启动参数,使用 java 命令运行打包文件:
java -jar test.jar

控制台输出运行结果:

hello java
  • 带上启动参数,使用 java 命令运行打包文件:
java -jar test.jar qbian 1 2

控制台输出运行结果:

启动参数:qbian
启动参数:1
启动参数:2
hello java

5.class文件结构

计算机只认识0和1,所以我们写的程序需要被编译器翻译成由0和1构成的二进制格式才能被计算机执行。

JVM只认识.class字节码,所以想要在JVM上运行的所有语言都需要编译为JVM能识别的字节码格式。

  • 魔数:它的唯一作用是用于确定这个文件是否为一个能被虚拟机接受的class文件。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以很随意的被改动。
  • 次版本号:class 文件的版本号。
  • 主版本号:class 文件的版本号,每个JDK大版本的发布,主版本号都会向上加1,高版本的JDK能向下兼容以前版本的class文件,但不能运行以后版本的class文件。
  • 常量池中常量的个数:由于常量池中常量的数量是不固定的,所以在常量池的入口需要设置一个数据表面常量池容量大小。
  • 常量池:常量池主要存放两大类常量,字面量和符合引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则包含三类常量(1:类和接口的全限定名、2:字段的名称和描述符、3:方法的名称和描述符)。
  • 访问标志:这个标志用于识别一些类或接口的访问信息,包括:这个class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等等。
  • 类索引:用于确定这个类的全限定名。
  • 父类索引:用于确定这个类的父类的全限定名。由于Java语言不容许多重继承,所以父类索引只有一个,除了 java.lang.Object 外,所有的Java类都有父类,因此除了 java.lang.Object 外,所有Java类的父类索引都不为0.
  • 接口索引集合容量大小:用于表示接口索引集合的容量大小。
  • 接口索引集合:用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口的索引集合中。
  • 字段表集合容量大小:用于表示字段表集合的容量大小
  • 字段表集合:用于描述接口或类中声名的变量,包括了类级变量或实例级变量,但不包括在方法内部声明的变量。包括以下信息:字段的作用域(public、private、protected修饰符)、是类级变量还是实例级变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、字段名称等。
  • 方法表集合容量大小:用于表示方法表集合的容量大小
  • 方法表集合:用于表示类或接口中定义的方法。和字段表集合类似。
  • 属性表集合容量大小:用于表示属性表集合的容量大小
  • 属性表集合:在class文件、字段表、方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。

6、JVM类加载步骤

虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

JVM中类型的加载和连接过程都是在程序运行期间完成的,这样会在类加载时稍微增加一些性能开销,但是却能为Java应用程序提供高度的灵活性,Java中天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特性实现的。例如,如果编写一个使用接口的应用程序,可以等到运行时再指定其实际的实现。

类的完整生命周期包括:加载(loading)、验证(verification)、准备(preparation)、解析(resolution)、初始化(initialization)、使用(using)、卸载(unloading)。其中验证、准备、解析这三个步骤可以统称为连接(linking)。

类加载

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

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在Java堆内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这些数据的访问入口。

虚拟机规范的这三点要求实际上并不具体,例如“通过一个类的全限定名来获取定义此类的二进制字节流”,并没有指明二进制字节流要从一个 class 文件中获取,没有指明要从哪里获取及怎样获取,例如:

  • 从 ZIP 包中读取,这很常见,最终成为日后JAR、EAR、WAR 格式的基础。
  • 从网络中获取,这种场景最典型的应用就是 Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术。
  • 由其他文件生成,典型场景:JSP应用。
  • 从数据库中读取,这种场景相对少见。

相对于类加载过程的其他阶段,加载阶段是开发期可控性最强的阶段,加载阶段既可以使用系统提供的类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以定义自己的类加载器去控制字节流的获取方式。

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区中,然后在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的这些类型数据的外部接口。

验证

这一阶段的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。我们前面已经说过,class文件不一定要求用Java源码编译而来,可以使用任何途径,包括用十六进制编辑器直接编写产生class文件。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

大致上验证会完成下面四个阶段的检验过程:

  1. 文件格式验证:验证是否符合class文件格式的规范,并且能被当前版本的虚拟机处理。主要验证点就是我们上一章所说class文件结构的一些信息。这一阶段的验证是基于字节流进行的,经过了这个阶段的验证之后,字节流才会进入内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构进行的。
  2. 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。例如是否有父类、父类是否被final修饰、类中的字段,方法是否与父类产生矛盾等。主要验证目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
  3. 字节码验证:主要进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。例如保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作、保证跳转指令不会跳转到方法体外的字节码指令上、保证方法体中的类型转换是有效的等。
  4. 符号引用验证:对类自身以外(常量池中的各种符号引用)的信息进行匹配性的校验,通常需要校验的内容有符号引用中通过字符串描述的全限定名是否能找到对应的类、在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段、符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问等。

准备

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

这个阶段中需要注意的两点:

  1. 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆内存中。
  2. 这里所说的初始值“通常情况”下是数据类型的零值。例如int类型的初始化是0、long类型的初始值是0L等。真正给类变量赋定义值的动作将在下面的初始化阶段才会被执行

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。包括:

  1. 类或接口的解析
  2. 字段解析
  3. 类方法解析
  4. 接口方法解析

初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(编译后的字节码)。

在准备阶段,变量已经被赋过一次变量默认的初始值,而在初始化阶段,则会根据程序中编写的具体值去初始化类变量和其他资源。

对于初始化阶段,虚拟机规范严格规定了有且只有四种情况必须立即对类进行“初始化”(加载、验证、准备自然需要在此之前开始);

  1. 遇到new、getstatic、putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条只能的最常见Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类进行发射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

有且仅有以上四种情况会执行类的初始化。

7、JVM类加载器

通过一个类的全限定名去获取描述此类的二进制字节流的动作被称为类加载器。这个动作被放到了Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。

类与类加载器

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性。也就是比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类是来源同一个class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。

这里所说的相等包括:class对象的equals()方法、isAssignableFrom()方法、isInstance()方法、使用instanceof关键字做对象所属关系判定等情况。

类加载模型

站在Java虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassCloader),这个类加载器使用c++语言实现,是虚拟机自身的一部分;其他所有类加载器都算作另外一种,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.classLoader。

从Java开发人员的角度看,类加载器还可以划分的更细致一些,绝大部分Java程序都会使用到以下三种系统提供的类加载器:

  1. 启动类加载器(Bootstrap ClassCloader):这个类加载器负责将存放在<JAVA_HOME>\lib 目录中的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中,启动类加载器无法被Java程序直接引用。
  2. 扩展类加载器(Extension ClassLoader):这个加载器由 sun.misc.launcher$ExtClassLoader 实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这三个类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

双亲委派模型要求除了最顶层的启动类加载器Bootstrap ClassLoader 外,其余所有类加载器都有父类加载器。这里的类加载器之间的父子关系,不是通过继承关系实现,而是通过组合关系实现的。

双亲委派的工作原理:

  1. 应用程序类加载器收到了类加载请求,首先不会自己尝试加载,而是调用父类扩展类加载器Extension ClassLoader去加载。
  2. 扩展类加载器收到类加载请求,首先不会自己尝试加载,而是继续调用父类启动类加载器Bootstrap ClassLoader去加载。
  3. 如果启动类加载器加载失败,例如在 <JAVA_HOME>/jre/lib 中没找到,会使用子类扩展类加载器Extension ClassLoader去加载。
  4. 若扩展类加载器Extension ClassLoader也加载失败,则会使用应用程序类加载器去加载。
  5. 若应用程序类加载器也加载失败,则会抛出ClassNotFoundException异常。

双亲委派类加载模型的好处在于:

  • 系统类防止内存中出现多份同样的字节码。
  • 保证Java程序安全稳定运行。

8、JVM内存模型

运行时数据区域

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6seUMHrJ-1690029261005)(https://static.developers.pub/JVM%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B.png)]

类加载器加载的class字节码保存到JVM内存的方法区,实例化类对象时在堆内存中生成对象信息,调用对象的方法时会在栈内存将方法进行压栈,如果发生线程切换,会将当前线程栈内存中调用信息保存到程序计数器中,当前线程获得CPU执行权时,将程序计数器中上次调用信息获取到,继续上次的位置接着执行。

方法区

方法区是线程共享的一块内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

Java虚拟机规范对这个区域的限制非常宽松,不需要连续的内存,可以选择固定的大小或者可扩展内存大小,也可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区是少见的。这个区域的内存回收主要是针对常量池的回收和对类型的卸载,一般来说这个区域的垃圾回收效果很难令人满意。

根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

运行时常量池是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对class文件常量池的另外一个重要特征是具备动态性。Java语言并不要求常量一定在编译期产生,也就是并非预置于class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入其中,例如使用的较多的string类的intern()方法。

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

加入运行时常量池后的方法区如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pwev3YTE-1690029261007)(https://static.developers.pub/JVM%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B-%E6%96%B9%E6%B3%95%E5%8C%BA.png)]

对大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这个区域分配内存。

Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC堆。如果从内存回收的角度看,现在的收集器基本上都采用的分代收集算法,所以Java堆还可以细分为:新生代和老龄代;在细致一点的有Eden、From Survivor空间、To Survivor空间等。

根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的存储空间中,只要逻辑上是连续的即可。在实现时既可以实现为固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展的来实现(通过 -Xmx 和 -Xms 控制)。

如果堆中没有内存完成实例分配,并且堆也无法再扩展,将会抛出 OutOfMemoryError 异常。

加入新生代和老龄代后的堆如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Feu0JDK-1690029261009)(https://static.developers.pub/JVM%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B-%E5%A0%86.png)]

Java方法栈

Java方法栈是线程私有的,它的生命周期与线程相同,线程结束栈内存也就释放,所以不存在垃圾回收。Java方法栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(stack frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在Java方法栈中从入栈到出栈的过程。

经常有人把Java内存区划分为堆内存和栈内存,这种划分的流行只能说明大多数程序员最关心的、与对象内存分配关系最密切的就是这两块。其中所指的堆我们前面有讲过,而所指的栈就是我们现在讲的Java方法栈,或者说虚拟机中的局部变量表部分。

局部变量表存放了编译器可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double)、对象引用、和returnAddress类型。局部变量表所需的内存空间在编译期完成分配,当进入方法时,这个方法需要在帧中分配多大的内存空间是确定的,在方法运行期间不会改变局部变量表的大小。

在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所容许的深度,将抛出 StackOverflowError异常;如果虚拟机可以动态扩展,当扩展时无法申请到足够的内存时将抛出 OutOfMemoryError 异常。

本地方法栈

本地方法栈和Java方法栈所发挥的作用是非常相似的,其区别不过是Java方法栈为虚拟机执行Java方法(class字节码)服务,而本地方法栈是为虚拟机使用到的native方法服务。虚拟机规范中对本地方法栈中的方法使用语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由的实现它。

与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError异常和 OutOfMemoryError 异常。

加入栈帧后的栈如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aykXcAMj-1690029261010)(https://static.developers.pub/JVM%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B-%E6%A0%88.png)]

程序计数器

程序计数器是较小的一块内存区域,是线程私有的,它的作用可以看成是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,即这块内存区域为线程私有的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,这个计数器值则为空(undefined)。

此内存区域是唯一一个在Java虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

9、对象的创建和访问

Java语言是一门面向对象语言,在程序运行过程中无时无刻都有新的对象被创建出来。在Java程序中,创建对象的方式有多种,除了最常用的new关键字外,我们还可以通过反射机制、Object.clone 方法、反序列化以及 Unsafe.allocateInstance方法来新建对象。

其中,Object.clone方法和反序列化通过直接复制已有的数据,来初始化新建对象的实例字段。Unsafe.allocateInstance方法则没有初始化实例对象字段。而new语句和反射机制则是通过调用构造器来初始化实例字段。

我们通过最常用的new关键字新建对象时,虚拟机新建对象时需要经历下面几个步骤:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tihBgo9b-1690029261011)(https://static.developers.pub/%E5%AF%B9%E8%B1%A1%E5%BB%BA%E7%AB%8B%E8%BF%87%E7%A8%8B.png)]

类加载检查

当JVM检测到一条new指令时,首先先检查该参数是否在常量池中定位到一个类的符号引用,并检查这个类的符号引用代表的类是否已被加载、解析和初始化。如果存在的话,JVM将直接使用已有的信息对该类进行操作。

如果没有,则执行相应的类加载过程。

分配内存

类加载检查通过后,虚拟机为新生对象分配内存,对象所需内存大小在类加载完成后就可以完全确定,为对象分配空间就是从Java堆中划分一块大小确定的内存。

不同的JVM垃圾收集器在分配内存时表现也不相同,具体表现有两种:

  1. 如果垃圾收集器选择的是基于压缩整理算法的,那么内存是规整的。所有用过的内存在一边,没有使用的在另一边,中间放着一个指针作为分界点的指示器。
  2. 如果垃圾收集器选择的是基于标记-清除算法的,那么内存不是规整的。已使用的内存和未使用的内存相互交错,虚拟机维护一个列表,记录哪些内存是可用的以及内存块的位置和大小。

必要的设置

内存分配结束后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

其次是对对象进行必要的设置,例如对象是哪些类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等(这个后面垃圾收集时再细说)信息。这些信息存放在对象的对象头之中。

初始化对象

当完成上述操作后,对象的内存分配成功了,但所有的字段都还是零值。此时会执行方法,把对象按照代码所写的那样进行初始化,从而产生一个真正可用的对象。

对象的内存布局

对象的内存布局可以分为三个区域:对象头、实例数据、对齐填充。

  1. 对象头:非固定的数据结构。一来是用来存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。二来是类型指针(对象指向它的元数据的指针),JVM通过这个指针来确定该对象是哪个类的实例对象,如果对象是一个Java数组,对象头中还需要有一块用来记录数组长度的数据。
  2. 实例数据:存储对象真正有效的数据,也就是程序代码中定义的各种类型的字段内容。不论是从父类继承的,还是子类定义的。这部分的存储顺序会受到Java源码中的定义顺序的影响。
  3. 对齐填充:不一定必须存在,起到占位符的作用。因为JVM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。故当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问方式

对象的访问在Java中无处不在,是最普通的程序行为,但即使是最简单的访问,也会涉及Java栈、Java堆、方法区这三个最重要内存区域之间的关联关系,如下面的这句代码:

Object obj = new Object();

假设这句代码出现在方法体中,那“Object obj”这部分的语义将会反映到Java栈的本地变量表中,作为一个reference类型数据出现。而“new Object()”这部分的语义将会反映到Java堆中,形成一块存储了Object类型所有实例数据值的构造化内存。另外,在Java堆中还需要包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

由于reference类型在Java虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。

使用句柄

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ed1H1m47-1690029261012)(https://static.developers.pub/%E9%80%9A%E8%BF%87%E5%8F%A5%E6%9F%84%E8%AE%BF%E9%97%AE%E5%AF%B9%E8%B1%A1.png)]

直接指针

如果使用直接指针访问方式,Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-segNfc4W-1690029261013)(https://static.developers.pub/%E9%80%9A%E8%BF%87%E7%9B%B4%E6%8E%A5%E6%8C%87%E9%92%88%E8%AE%BF%E9%97%AE%E5%AF%B9%E8%B1%A1.png)]

这两种对象的访问方式各有优势,使用句柄访问方式的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。

使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,对于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

我们使用最多的主流虚拟机Sun HotSpot就是使用的直接指针的方式进行对象访问的。

10、内存分配与回收

新生代和老龄代

对象的内存分配主要就是在堆上进行分配。所以我们先来看一下堆内部布局,堆可以划分为两大块内存:新生代和老龄代,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-StTBKNXI-1690029261014)(https://static.developers.pub/%E5%A0%86%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80-%E6%96%B0%E7%94%9F%E4%BB%A3-%E8%80%81%E9%BE%84%E4%BB%A3.png)]

堆内存大小 = 新生代 + 老龄代

针对新生代和老龄代的垃圾回收分别称为Minor GC和Major GC/Full GC。

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集行为,因为Java对象大多都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老龄代GC(Major GC/Full GC):指发生在老龄代的GC,出现Full GC,经常会伴随至少一次的Minor GC,Full GC速度一般比Minor GC慢很多。

新生代和老龄代之间的垃圾回收主要采用的是分代回收算法。即当新生代中的对象被多次Minor GC后还存活并且年龄增长到某个阈值时,就会被移动到老龄代中。

伊甸园和交换区

新生代还可以再细分,又可以分为两大块:伊甸园(Eden)和交换区(Survivor),默认占比是8:2,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PtoC5bja-1690029261015)(https://static.developers.pub/%E5%A0%86%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80-%E4%BC%8A%E7%94%B8%E5%9B%AD-%E4%BA%A4%E6%8D%A2%E5%8C%BA.png)]

堆内存大小 = 伊甸园 + 交换区 + 老龄代

新建的对象主要分配在新生代的伊甸园中,伊甸园中的垃圾回收主要采用的是标记-清除算法,当Minor GC时,如果当前对象还存活就会被移动到新生代的交换区中。

from空间和to空间

新生代中的交换区也可以再细分一下,分为from空间和to空间,默认占比是1:1,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yesIvWHz-1690029261016)(https://static.developers.pub/%E5%A0%86%E5%86%85%E5%AD%98%E5%B8%83%E5%B1%80-from-to.png)]

堆内存大小 = 伊甸园 + from空间 + to空间 + 老龄代

交换区中from空间和to空间的垃圾回收主要采用的是复制算法,每次只使用其中的一块,当这一块的内存用完时,就将还存活的对象移动到另一块上面,然后把已使用的内存空间一次性的清理掉。

新生代的总可用空间[9] = 伊甸园[8] + (from[1] || to[1])

描述

接下来我们所说的对象分配都是针对上图内存模型展开讲解。

大多数情况下,新建的对象主要分配在新生代的伊甸园(Eden)中。当伊甸园中没有足够空间进行分配时,虚拟机将发起一次Minor GC(针对新生代区域的垃圾回收)。

虚拟机针对新生代和老龄代的内存区域,采用了分代收集的思想来管理。那内存回收时就必须能识别哪些对象应当放在新生代,哪些对象应当放在老龄代。为了做到这一点,虚拟机为每个对象定义了一个对象年龄计数器(在对象头数据中)。如果对象在Eden区出生并且经过一次Minor GC后仍然存活,并且能被交换区容纳的话,将被移动到交换区中,并将对象年龄设为1。对象在交换区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老龄代中。对于晋升老龄代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。

为了能更好的适用不同程度的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老龄代,如果在交换空间中相同年龄所有对象大小的总和大于交换空间的一半(大于from或to空间),年龄大于或等于该年龄的对象将直接进入老龄代,无需等到MaxTenuringThreshold来设置要求的年龄。

堆内存回收

介绍堆内存回收前我们先说一下怎么判断对象已死(可以回收),Hotspot虚拟机是通过根搜索算法来判断某个对象是否存活的。

根搜索算法:通过一系列的名为“GC Roots”的对象作为起始点,从这些起始点开始向下搜索,搜索所走过的路径称为引用链,当一个对象不在任何引用链上时,则证明这个对象是不可用的(用图论的说法就是这个对象到GC Roots不可达)。如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bqRBZank-1690029261018)(https://static.developers.pub/%E6%A0%B9%E6%90%9C%E7%B4%A2%E7%AE%97%E6%B3%95.png)]

在Java虚拟机中,可以用作GC Roots的对象主要包括下面几种:

  1. 虚拟机栈中(栈帧中的本地方法表)的引用对象;
  2. 方法区中的类静态属性引用的对象;
  3. 方法区中的常量引用的对象;
  4. 本地方法栈中JNI(一般说的native方法)引用的对象;

在根搜索算法中不可达的对象,并不是“非死不可”的,要真正宣布一个对象的死亡,至少要经历两次标记过程。若两次标记都为根不可达对象,才会真正去回收它。

方法区回收

很多人认为方法区(Hotspot虚拟机中的永久代)是没有垃圾回收的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收的性价比比较低,在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾回收效率却远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量和回收堆中的对象非常类似。以常量中的字面量的回收为例,加入一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫“abc”的,也就是没有任何String对象引用常量池中的“abc”常量,也没有在其他地方引用这个字面量,如果这个时候发送内存回收,而且必要的话,这个“abc”常量就会被系统从常量池中删除。常量池中的其他类、接口、方法、字段的符号引用也与此类似。

判定一个常量是否是废弃常量比较简单,而要判定一个类是否是无用的类的条件则相对苛刻很多。

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类任何的实例对象。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问到该类的方法。

虚拟机可以对满足上述3个条件的类进行回收,这里仅仅是可以回收,而不是和对象一样,不使用了就必然要回收。

地方法栈中JNI(一般说的native方法)引用的对象;

在根搜索算法中不可达的对象,并不是“非死不可”的,要真正宣布一个对象的死亡,至少要经历两次标记过程。若两次标记都为根不可达对象,才会真正去回收它。

方法区回收

很多人认为方法区(Hotspot虚拟机中的永久代)是没有垃圾回收的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾回收,而且在方法区进行垃圾回收的性价比比较低,在堆中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,而永久代的垃圾回收效率却远低于此。

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量和回收堆中的对象非常类似。以常量中的字面量的回收为例,加入一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫“abc”的,也就是没有任何String对象引用常量池中的“abc”常量,也没有在其他地方引用这个字面量,如果这个时候发送内存回收,而且必要的话,这个“abc”常量就会被系统从常量池中删除。常量池中的其他类、接口、方法、字段的符号引用也与此类似。

判定一个常量是否是废弃常量比较简单,而要判定一个类是否是无用的类的条件则相对苛刻很多。

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类任何的实例对象。
  2. 加载该类的 ClassLoader 已经被回收。
  3. 该类对应的 java.lang.Class 对象没有任何地方被引用,无法在任何地方通过反射访问到该类的方法。

虚拟机可以对满足上述3个条件的类进行回收,这里仅仅是可以回收,而不是和对象一样,不使用了就必然要回收。

在大量使用反射,动态代理,CGLib等 bytecode 的场景下,以及动态生成 JSP 和 OSGi 这类频繁自定义 ClassLoader 的场景下都需要虚拟机具备类卸载的功能,以保证永久代不会内存溢出。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值