有头发且有趣的码农万里挑一:06有料叔——程序猿解析JVM内存结构
有头发且有趣的码农万里挑一~

[id_413266381]
有料叔 | 一位有故事的程序猿
JVM内存结构
可以看出,JVM 从宏观角度来看,可以划分成“内部”以及“外部”这两个部分,这样便于记忆和理解。
内部包含以下两个部分:一是线程共享(公有)的数据区;二是线程隔离(私有)的数据区。
“外部”包含以下这些方面:有类加载子系统;还有垃圾回收器;以及执行引擎;包含本地库接口;并且有本地方法库。
以上部件构成了整个jvm,接下来我们一个一个零件拆开了看。
class文件
一个 java 文件能够借助编译工具(javac)被编译成 class 字节码文件,接着通过 jvm 来进行加载和运行。由于 jvm 把底层操作系统的差异给屏蔽了,具备平台无关性,所以能够一次编译然后在各处运行。
类加载子系统
类加载子系统的职责是查找并装载 class 文件,然后把 class 文件中的二进制数据加载到 jvm 当中。
字节码被加载,接着进行验证,然后是准备,之后进行解析,最后是初始化。
通过类的完全限定名能够找到类文件所在的位置,依据其中的字节码可以创建 java.lang.Class 对象,正因如此才会有万物皆对象的说法,我们还可以继承 ClassLoader 并重写 findClass 方法,以此来自定义实现类加载器。在默认情况下,我们都使用 AppClassLoader。
验证的目的是确保加载的字节码符合虚拟机的要求,这是 Java 提供的一种自我保护机制,目的是防止字节码危害虚拟机安全。这种验证主要包括四种,分别是字节码验证、文件格式验证、元数据验证和符号引用验证。
准备:为类变量分配地址,同时进行初始化值的设定。类变量会被分配到方法区(元空间)里。这里所说的初始化,指的是该数据类型的默认初始值,像 int 对应的是 0,long 对应的是 0L。只有在初始化这个阶段,才会进行显示赋值的动作。
解析:将类中的二进制数据所含的符号引用转化为直接引用;比如我们借助 user.getInfo(),这里的.getInfo()属于符号引用,在解析阶段会让它指向实际的内存位置,此即为直接引用。
初始化主要做的事情是给类的静态变量赋予正确的值,例如将 int num 赋值为 10,此时 num 的值会从准备阶段的 0 转变为 10;倘若该类存在父类,那么就会对父类进行初始化操作;要是类中存在初始化语句,系统会依照顺序来进行初始化。
双亲委派模式
双亲委派:自底向上检查是否加载成功,自顶向下尝试加载。
当一个类加载器收到类加载请求时,它不会自行进行加载。它会把这个请求丢给父类加载器。如果父类加载器还有父类,就会依次向上请求。一直到到达顶级加载器。如果父类加载器能够完成加载,就会返回加载成功。否则,子类加载器才会自己尝试进行加载。
System 输出 Test 类的类加载器。
System.out.println(Test 类的类加载器的父类加载器);
System.out.println(获取 Test 类的类加载器的父类加载器的父类加载器());
System 输出类 String 的类加载器。
通过代码进行验证,能够较为容易地知晓 AppClassLoader 与 ExtClassLoader 以及 BootstrapClassLoader 这三层之间的关系。
类加载的三种方式
1. new关键字加载
User user =newUser();
静态加载,在运行时候通过new关键字创建类实例
2.Class.forName()加载
通过 Class.forName("User") 来获取 Class 实例,将其赋值给 clazz 这个变量。
创建一个类的实例,这个实例是通过 clazz 类的 newInstance()方法创建出来的。
动态加载的方式是通过 Class.forName() 来加载类,接着调用类的 newInstance() 方法以实现对象的实例化。
ClassLoader 实例的这个方法叫做 loadClass()
Object user=clazz.newInstance();
动态加载的方式是可以通过继承 ClassLoader 来实现自定义类加载器。
线程私有和线程公有
JVM内存区从宏观上可以分为线程私有和线程公有两块。
线程私有部分
这部分不存在线程安全方面的问题,它会随着线程的执行完毕而结束。它包含着程序计数器、虚拟机栈以及本地方法栈这三个部件。
程序计数器:
程序计数器又被称作 PC 寄存器。它的作用在于,当 cpu 进行切换时,能够指向当前时刻需要获取指令的那个位置。
特点:
线程私有
一块较小的区域
记录程序执行的位置
不存在内存溢出OutOfMemoryError
虚拟机栈:
栈数据结构的实现中,入口和出口都只有一个。这两个操作分别被称为入栈和出栈。其特点是先进后出(FILO)。
栈的主要作用是执行方法。最先执行的方法处于最下方,接着依次进行放置。当方法执行完毕后,会从上到下依次退出。因此,方法的执行就是向栈中压入元素,而方法的结束就是从栈中弹出元素(销毁栈帧)。
publicvoidstart(){
say();
run();
虚拟机栈如何执行
栈帧
栈帧位于 Java 虚拟机栈内,它是虚拟机栈里的基本单位元素。当方法开始执行时,就会创建栈帧,每一个方法对应着一个栈帧,并且一个栈帧包含四个部分:
1. 局部变量表
存放方法参数,或者是内部定义的一组变量列表;例如在方法中声明的对象:
创建了一个名为 user 的局部变量,其类型为 User,即 newUser() 所代表的类型。
2. 操作数栈
在执行字节码指令时使用,通俗来讲,方法的执行是在操作数栈中进行的,通过压栈和出栈来进行访问。
3. 动态链接
Java运行期间是动态链接的,需要把指向方法的符号引用转变为直接引用(即内存地址);在类加载解析阶段,把符号引用转变为直接引用这种行为被称作静态解析。而这里恰恰就是动态链接。
找到这个 getInfo()方法所在的内存位置。
4. 返回地址
方法不管正常执行结束还是异常退出,需要返回方法被调用的位置
以上四个部分对应着方法执行的过程。在虚拟环境中存在着许多个栈帧,并且每个方法都对应着一个栈帧。
使用 bin/javap.exe 文件对一个 class 文件进行反汇编,能够查看出上述四个部分。
栈溢出是指当栈的深度超过虚拟机所允许的范围时,就会报 StackOverflowError。而 -Xss 这个参数可以用来设置栈的大小。
10
11
12
13
/** 递归演示如何栈溢出 */
publicstaticintnum =0;
publicstaticvoida(){
num++;
a();
公共静态 void 主函数(String 数组 args) {
try{
a();
}catch(Exception ex){
System.out 会打印出“调用次数:”加上 num 的值。
内存溢出的情况是,当栈需要进行扩展时却无法申请到空间,此时就会报 OutOfMemoryError 。
本地方法栈
本地方法栈和虚拟机栈有相似之处。其区别在于,虚拟机栈主要是为 jvm 执行字节码提供服务,而本地方法栈则是为 Native 方法服务,也就是为本地方法提供服务。因此,本地方法栈同样是一块内存私有区域,并且和虚拟机栈一样,也会存在同样的异常问题。
特点:
与虚拟机栈基本类似
区域是本地方法栈会为 Native 方法提供服务,比如在 Windows 系统下会调用 dll 文件。
Sun HotSpot将虚拟机栈和本地方法栈合并
存在 StackOverflowError 以及 OutOfMemoryError
线程公有部分
这部分存在线程安全方面的问题。平常我们所提及的内存优化问题以及溢出等问题,都需要对这个区域予以关注。这个区域包含堆以及方法区(也叫元空间)这两个部件。
方法区(元空间)
类加载器在加载类的时候,会把一些类的元数据信息(字节码)留存于这个区域。比如类变量、静态方法以及普通方法等。并且,方法区是线程共享的,多个线程能够使用同一个类。
jdk1.7合并方法区到了堆里面
从逻辑上可以认为它是在堆中。
特点:
线程共享
存储类信息、常量、静态变量、方法描述等信息
HotSpot虚拟机中称之为永久代
GC很少回收这个区域
存在 OutOfMemoryError 这种情况,能够通过 -XX:MaxPermSize 来设置其大小。
堆
堆用于存放所有实例化对象和数组,堆中的信息是线程共享的。它是所有 jvm 部件中分配内存最大的区域,在虚拟机启动时就被创建。垃圾回收器主要对该区域进行管理。堆分为新生代和老年代,新生代占堆内存的 1/3,老年代占堆内存的 2/3。新生代更为细致,可分为 Eden、From Survivor、To Survivor 空间,比例为 8:1:1。可以通过 -Xmx、-Xms 设置堆的大小。
在堆中生成了一个实例对象或者数组。可以在栈中声明一个变量,此变量用来指向堆中的对象。该变量的取值与堆中对象的内存地址相等。因此,当我们打印变量名时,显示的是一串内存地址。
Test test =newTest();
System.out 会输出 test,输出的内容为 Test@1b6d3586
万物皆为对象。在实际开发过程中,当我们创建了众多对象时,为避免内存泄露,java 会确保对内存进行有效使用,并且由 java 虚拟机的自动垃圾回收器来进行管理。同时,java 会把堆划分成新生代和老年代进行管理。
新生代与老年代
新生代是 Java 对象诞生的场所,也是新对象分配内存的地方。大多数对象的存活时间并不长,所以这个区域会常常触发 MinorGC 来进行垃圾回收。
老年代存放的对象存活时间较久或者内存较大。因此,Full GC 不会频繁执行。
Minor GC
新生代中发生的垃圾回收机制采用复制算法,该算法会扫描存活对象并将其复制到新的内存空间中。From Survivor 和 to Survivor 是相对的概念。当 Minor GC 发生时,Eden 区和其中一个 Survivor 区会把一些仍然存活的对象放置到另一个 Survivor 区,接着清理 Eden 区和之前的 Survivor 区。下次 Minor GC 时也是如此。当对象达到一定“年龄”后,新生代会将其放入老年代,每发生一次 Minor GC,对象的年龄就增加 1 岁,默认情况下对象达到 15 岁时会进入老年代。
Full GC
老年代中发生的垃圾回收机制是标记 - 清除,也就是标记存活的对象,清除未标记的对象,也就是需要回收的对象。由于老年代中的对象比较稳定,所以 Full GC 发生的频率相较于 Minor GC 要少,不过一次回收所花费的时间会比 Minor GC 更长。