一、内存区域
1、运行时数据区域
jdk1.8之前:
jdk1.8之后:
1.1、程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,顺序执行、分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
程序计数器是唯一一个不会出现 OutOfMemoryError
的区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.2、Java 虚拟机栈
实现所有java方法的调用。每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常结束还是异常结束都算作方法结束。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。
- 局部变量表
主要存放了方法参数、方法内部的局部变量(基本数据类型变量、对象引用(reference 类型,详见5对象的访问))。 - 操作数栈
用于存放方法执行过程中产生的中间计算结果,及计算过程中产生的临时变量。 - 动态链接
主要服务一个方法需要调用其他方法的场景。动态链接的作用就是为了将符号引用转换为直接引用(详见3常量池 运行时常量池)。 - 方法返回地址
栈可能会出现两种错误(循环调用、递归):
StackOverFlowError
: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出StackOverFlowError
错误。OutOfMemoryError
: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常。
栈的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.3、本地方法栈
实现所有Native方法的调用。工作原理和Java 虚拟机栈一样。也会出现 StackOverFlowError
、OutOfMemoryError
两种错误。生命周期也是随着线程的创建而创建,随着线程的结束而死亡。
1.4、堆
此区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
堆最容易出现OutOfMemoryError
错误:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
: 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:当在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。
堆的生命周期由GC决定。
1.5、方法区
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。
方法区中保存着类信息、字段信息、方法信息、即时编译器编译后的代码缓存等数据。
永久代(1.8之前)和元空间(1.8及之后)都是方法区的一种实现方式。用元空间替换永久代的原因:动态加载类越来越多,方法区所需要的空间会越来越大,永久代存在于JVM的内存中,受制于JVM的内存大小;元空间存在于直接内存中,直接内存的大小取决于系统的内存大小。
方法区也会出现OutOfMemoryError
错误。
关闭JVM就会释放这个区域的内存。
1.6、各区域存储内容
2、直接内存
正常IO读取
当java程序需要读取文件时:磁盘——系统内存——java堆内存——java程序。
直接内存读取
当java程序需要读取文件时:磁盘——直接内存——java程序。直接内存被系统内存和java堆内存共享,省去将文件从系统内存复制到java堆内存的开销。
直接内存也会出现OutOfMemoryError
错误。
3、常量池 运行时常量池
Java 源文件被编译成字节码文件时,在字节码文件中会生成一个常量池(也称class常量池或静态常量池),常量池中保存了字面量和符号引用。
jvm加载类时,会将常量池中的字面量和符号引用保存到运行时常量池中,解析阶段会将常量池中的符号引用转换为直接引用存放到运行时常量池中(直接引用:类在jvm中实际的位置,可以是一个指针)。
静态解析:类加载时或者第一次使用时,执行符号引用转化为直接引用。
动态链接:每一次使用时都执行符号引用转化为直接引用。
运行时常量池存在于方法区。
4、字符串常量池
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7及之后,字符串常量池存放在堆中(静态变量也是)。原因:字符串常量池中通常有大量的字符串需要被回收,所以将字符串常量池存放在堆中可以使之得到高效及时的回收。
5、对象的访问
java通过虚拟机栈上的reference数据来访问堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:句柄、直接指针。
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针
如果使用直接指针访问,reference 中存储的直接就是对象的地址。
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
二、垃圾回收
1、判断对象死亡的方法
1.1、引用计数法
给对象添加一个引用计数器:
每当有一个地方引用它,计数器就加 1;
当引用失效,计数器就减 1;
当计数器为0时,对象就宣布死亡。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。
1.2、可达性分析算法
可达性算法是目前主流的虚拟机都采用的算法,程序把所有的引用关系看作一张图,从一个节点GC Roots开始,寻找它的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈中引用的对象(存在于栈帧中的局部变量表);
- 方法区中静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Native方法)引用的对象 等。
可以得出对象实例1、2、4、6都具有对象可达性,也就是存活对象,不能被GC回收的对象。而对象实例3、5虽然直接相连,但并没有任何一个GC Roots与之相连,即GC Roots不可达对象,就会被GC回收。
2、垃圾回收的方法
2.1、标记-清除算法
该算法分为“标记”和“清除”两个阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收所有没有被标记的对象。
标记的过程就是做可达性分析的过程,给所有具有可达性的对象打上标记,代表这些对象不需要被回收。
缺点:
- 效率问题
标记和清除阶段都需要停止应用程序,这会导致非常差的用户体验。而且标记和清除时,都需要遍历所有对象,很耗费时间。 - 空间问题
标记清除之后会产生大量不连续的内存碎片,可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不先提前触发一次垃圾回收动作。
2.2、标记-复制算法
该算法是将内存空间分成大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
注意,在标记的时候,就已经复制了还存活的对象。
每次只使用一半的内存空间,大大增加了标记时的遍历效率。清除时也不需要遍历了,直接一次清理掉。而且也不会出现不连续的内存碎片。
缺点:
- 每次只使用一半的内存,另一半的内存浪费掉了。
- 如果对象的存活率很高,复制对象的耗时也大大增加。
2.3、标记-整理算法
首先对存活的对象进行标记,然后将存活的对象移动到一端,最后清理端边线以外的内存。
解决了标记-清除算法会出现不连续的内存碎片问题,还解决了标记-复制算法浪费一半内存的问题。
缺点:
- 效率太低,不仅要遍历标记,还要移动对象,整理它们的引用地址。
2.4、分代收集算法
分代收集算法的思想是按对象的存活周期不同将内存划分为多个部分,一般是把 Java 堆分为新生代和老年代(可能还有永久代),这样就可以根据各个年代的特点采用最合适的收集算法。
新生代中每次垃圾回收都会有大量的对象死去,只有少量存活,因此采用标记-复制算法回收新生代,只需要付出少量对象的复制成本就可以完成收集。
老年代中对象的存活率高,需要使用标记-清除算法或者标记-整理算法来进行回收。
新生代又分为Eden区和Survivor区(Survivor from、Survivor to),大小比例默认为8:1:1。新产生的对象优先进去Eden区,当Eden区满了之后再使用Survivor from,当Survivor from也满了之后就进行Minor GC(新生代GC),将Eden和Survivor from中存活的对象复制到Survivor to,然后清空Eden和Survivor from,这个时候原来的Survivor from成了新的Survivor to,原来的Survivor to成了新的Survivor from。复制的时候,如果Survivor to无法容纳全部存活的对象,则将对象复制到老年代,如果老年代也无法容纳,则进行Full GC(老年代GC)。
长期存活的对象进入老年代,JVM给每个对象定义了一个对象年龄计数器,如果对象出生后经过第一次Minor GC仍然存活,并且能被Survivor容纳,那么该对象将被移入Survivor并且年龄设定为1。每熬过一次Minor GC,年龄就加1,当他的年龄到一定程度(默认为15岁,可以通过配置修改),就会移入老年代。但是JVM并不是永远要求年龄必须达到最大年龄才会进入老年代,如果Survivor 空间中相同年龄(如年龄为x)所有对象大小的总和大于Survivor的一半,年龄大于等于x的所有对象直接进入老年代,无需等到最大年龄要求。
大对象直接进入老年代:JVM中有个参数配置-XX:PretenureSizeThreshold
,令大于这个设置值的对象直接进入老年代,目的是为了避免在Eden和Survivor区之间发生大量的内存复制。
三、类加载
1、类的生命周期
2、类的加载过程
类加载过程主要分为三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
- 加载
- 验证
- 准备
- 解析
- 初始化
3、类加载器
JVM 中内置了三个重要的 ClassLoader
,除了 BootstrapClassLoader
其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader
:
BootstrapClassLoader
(启动类加载器) :最顶层的加载类,由 C++实现,负责加载%JAVA_HOME%/lib
目录下的 jar 包和类,或者被-Xbootclasspath
参数指定的路径下的jar 包和类。ExtensionClassLoader
(扩展类加载器) :负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类,或者被java.ext.dirs
系统变量指定的路径下的 jar 包和类。AppClassLoader
(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath
下的 jar 包和类。
3.1、双亲委派模型
优点:
可以避免类的重复加载。
3.2、自定义类加载器
继承java.lang.ClassLoader
即可。