首先是Class File(即类文件),然后使用类加载子系统将.class文件加载进内存空间。
然后执行引擎执行.class字节码文件,进入运行时数据区。
类加载子系统主要是将.class
文件加载进内存空间,至于能否运行取决于执行引擎(Execution Engine)
它主要包括三部分
-
加载:将
.class
文件加载进内存空间,在方法区生成一个java.lang.Class
的信息,根据字节码获取字节流信息,加载进内存空间。- 引导类加载器(也叫启动类加载器)(
Bootstrap ClassLoader
,使用C语言编写) - 自定义类加载器(所有继承自
ClassLoader
的类,使用Java语言编写)- 扩展类加载器(
Extension ClassLoader
)(Java中的实现为Launcher$ExtClassLoader
类,其父加载器是Bootstrap ClassLoader
) - 系统类加载器(
System ClassLoader
)(Java中的实现为Launcher$AppClassLoader
类,其父加载器是Extension ClassLoader
)
- 扩展类加载器(
- 引导类加载器(也叫启动类加载器)(
-
链接
- **验证:**验证加载进内存的字节流是否符合虚拟机要求,保证类加载的正确性。
- **准备:**为类变量分配内存并设置该变量的默认值,即零值,不包括final修饰的static
- **解析:**将常量池中的符号引用转变为直接引用的过程,创建并初始化虚方法表
-
初始化
- 初始化就是类的
<clinit>
方法的执行 - 这个方法是Java自动生成,会将所有的静态初始化或者静态代码块全部放在这个方法里面,进行显式地赋值。
- 初始化就是类的
- 如果一个类加载器收到了类加载的请求,它首先并不会自己去加载,而是把这个请求委托给父类去加载
- 如果父类加载器还存在父类加载器,则继续向上请求,依次递归,直到到最顶层的启动类加载器
- 如果父类加载器可以完成加载任务,则由父类加载,加载完成后返回,如果父类不能加载,子加载器才会尝试自己去加载。
Java程序对类的使用方式分为:主动使用和被动使用,被动使用不会导致类的初始化
HotSpot主要包括五部分,其他虚拟机不一定包含所有的五部分,不同的虚拟机架构也不同。
- 程序计数器(PC计数器)
- 虚拟机栈
- 本地方法栈
- 堆
- 方法区
其中,程序计数器、虚拟机栈、本地方法栈是每个线程都有一份的,而堆和方法区是所有线程共享的。
用来存储下一条指令的地址,由执行引擎读取下一条指令。
不会出现异常和垃圾回收。
生命周期和线程的生命周期一致。
栈是运行时单位,堆是存储时的单位。
JAVA指令都是根据栈的指令来设计的。指令简单,跨平台性好,但是执行相同的运算需要更多的指令。
另外一种是基于寄存器的指令系统,与硬件的耦合性更高,指令更复杂,但是执行速度更快一些。
是什么:每个线程在创建时都会创建一个虚拟机栈,其中保存的是一个个的栈帧,对应一个个的方法。
生命周期:生命周期和线程的生命周期一致。
作用:主要保存方法运行过程中的局部变量、部分结果、并参与方法的返回和调用。
异常:StackOverflowError异常和OutOfMemoryError异常。
没有垃圾回收机制。
设置栈的大小:使用-Xss参数来设置栈空间的大小。
栈中存储的是一个个的栈帧,一个栈帧从入栈到出栈的过程,意味着一个方法从开始执行到执行完毕的过程。
方法有两种结束方式:①正常执行方法,正常退出②方法执行中出现未捕获的异常,以抛出异常的方式结束。
每个栈帧中都保存着:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
- 一些附加信息
局部变量表的大小在编译期就已经确定了,保存的就是方法中的局部变量,包括方法参数、内部局部变量、如果是类的实例方法,即非static方法,内部还有一个this变量,索引是0,即最先放入局部变量表中。
局部变量表是一个数组,单位是一个个的Slot(槽)
,32位及以下的数据占据一个槽,而像long和double64位类型的数据占用两个Slot,引用类型的数据占用一个槽。
栈帧中的局部变量表是可以重复利用的,如果一个参数过了其作用域,则其占据的槽可以后面的参数重复利用,以达到节省资源的目的。
静态变量和局部变量的区别:
静态变量会经过两次初始化:①在类加载子系统中的链接的准备阶段,会初始化为默认值②在类加载子系统的初始化阶段会执行<clinit>
方法,对静态变量显式初始化。
而局部变量则不会被系统初始化,要使用的话必须自己进行初始化。
局部变量表中的变量是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
- 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据。
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 当一个方法刚开始执行的时候,一个新的栈帧就被创建出来了,这个方法的操作数栈是空的。
- 操作数栈是使用数组实现的,大小在编译期间就已经确定了。
- 栈中占用的空间和局部变量表占用的空间一致,32位一个,64位两个。
- 如果被调用方法有返回值的话,返回值会被压入当前栈帧的操作数栈中。
- Java虚拟机的解释引擎是基于栈的执行引擎,其中栈就是操作数栈。
DynamicLinking
-
在JVM中,将符号引用转换为调用方法的直接引用于方法的绑定机制相关。
-
分为静态链接和动态链接,分别对应早期绑定和晚期绑定。
-
** 早期绑定**是在编译器即可确定执行的具体是哪个方法
-
晚期绑定指的是在编译期间无法确定的,只有在运行期间才可以确定的方法。
-
方法分为虚方法和非虚方法
-
非虚方法就是指在编译时期就确定了具体的调用版本,这个版本在运行时是不可变的。
-
非虚方法主要有:静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
-
虚拟机中提供了五种方法调用的指令
invokestatic
:调用静态方法invokespecial
:调用<init>
方法、私有及父类方法invokevirtual
:调用所有虚方法invokeinterface
:调用接口方法invokedynamic
:动态解析出需要调用的方法,然后执行,使用Lambda
表达式时,使用的就是invokedynamic
方法重写的本质:解析和分派(找到操作数栈顶的第一个元素所执行的对象的实际类型,然后在这个类中找到与常量中的描述符合名称符合的方法,然后进行访问权限校验,如果有权限,则直接返回这个方法的直接引用,否则抛出IllegalAccessError
异常,然后如果没在对象中找到对应的方法,则根据继承关系一层一层往上找。
因为要经常使用动态分派,为了提高性能,JVM采用在类的方法区建立一个虚方法表来实现,通过索引来代替查找。
虚方法表会在类加载的链接的解析阶段被创建并初始化完毕。
方法返回地址中保存的是执行的当前方法的外部方法的下一条指令
虚拟机栈用于Java方法的调用,而本地方法栈则用于管理本地方法的调用。
堆是运行时数据区中最大的一部分了,也是垃圾回收的重要区域。
堆的内存划分:
-
JDK7时,分为新生代+老年代+永久代
-
JDK8时,分为新生代+老年代+元空间
-
其中新生代又分为
Eden
+S0
+S1
区,其中默认比例是8:1:1
-
新生代和老年代的默认比例是
1:2
-
在Eden区又存在一个线程独享的TLAB区,默认为Eden区的
1%
-
可以使用
-Xms
设置堆空间起始大小,可以使用-Xmx
设置堆空间最大内存大小 -
默认起始空间是物理内存的1/64,默认最大内存是物理内存的1/4
-
设置堆空间起始内存大小和最大大小时,一般将两者设置为相同大小,为了提高性能(Java垃圾回收机制在收集完堆区之后不用重新分隔计算堆区的大小)。
-
可以使用
-XX:newRatio=<N>
设置新生代和老年代的比例 -
可以使用
-XX:SurvivorRatio=<N>
设置Eden和S0以及S1的比例 -
系统会根据内存默认自适应分配Eden和S0区的比例,可以使用
-XX:-UseAdaptiveSizePolicy
参数关闭自适应分配,但是好像并不起作用,最好使用-XX:SurvivorRatio=8
显式地设置。 -
几乎所有的对象都是在Eden区new出来的。
- new出来的对象首先放在Eden区,这个区有大小限制。
- 当Eden区满的时候,进行一次Minor GC,对新生代中Eden区和From区进行垃圾回收,然后将未被引用的对象销毁掉,然后在Eden区new出新对象。
- 将未被销毁的对象放进新生代中的To区。
- To区和From区是在每一次垃圾回收后轮流切换的
- 当在这两个区中来回的转,到达15次时,会被转移到老年代。
- 可以通过
-XX:MaxTenuringThreshold=<N>
设置这个阈值
- 可以通过
- 当养老区内存也不够了时,再次触发GC:Major GC
- 如果Major GC后内存还是不足,则会抛出OutOfMemoryError异常。
其中针对S0和S1区,谁空谁是To区
首先回收新生代、其次回收老年代、几乎不在永久代/元空间回收
其中如果要分配对象过大,Eden区在Minor GC之后还是放不下,则直接放入老年代,如果老年代也放不下,则进行一次Full GC,如果还是放不下,则抛出OutOfMemoryError异常。如果进行Minor GC后,S区放不下存活的对象,则也是直接放入老年代。
如果在S区的对象其中相同年龄的对象占S区内存的一半以上,则相同年龄及以上的直接放入老年代。
S区满了并不会触发Minor GC,只有Eden区满了,然后S区被动进行垃圾回收
TLAB(Thread Local Allocation Buffer)是每个线程在Eden区又单独划分出来的一个小区域。首先会在TLAB中不加锁分配对象,如果分配失败,则使用加锁机制确保数据操作的原子性,直接在Eden区进行分配内存。
由于逃逸分析技术的逐渐成熟,栈上分配、标量替换会导致对象的分配是在栈上面进行的
逃逸分析是指:如果一个对象在方法中被创建,对象只在方法内部使用,不会逃逸到方法外部,此时就可以使用栈上分配的方式为对象分配内存。
所以在方法中,尽量使用局部变量,不要在方法外定义。
逃逸分析:代码优化
一、栈上分配
二、同步省略
三、分解对象或标量替换,就是将一个对象(聚合量)分解成一个一个的标量,然后保存在局部变量表中
HotSpot默认没有使用栈上分配,只使用了标量替换,所以虚拟机中所有对象还是都在堆区
方法区内部结构:
类型信息(属性信息,方法信息),运行时常量池,静态变量,JIT代码缓存
-
方法区是位于运行时数据区的独立于堆外的内存空间,也叫非堆区。
-
是线程共享的区域,也是在JVM启动时被创建的。
-
可以设置成固定大小,也可以设置成可扩展的。
-
在JDK7中,是永久代
-XX:PermSize=10m
设置永久代的默认内存大小-XX:MaxPermSize=100m
设置永久代的最大内存大小- 默认大小为20.7M,32位最大为64M,64位最大为82M
-
在JDK8中,是元空间
-XX:MetaspaceSize=10m
,设置元空间默认内存大小-XX:MaxMetaspaceSize=100m
,设置元空间最大内存大小- 默认大小为21M,最大值为-1,即无限制,最大为物理内存的限制
- 默认大小是一个水位线,如果超过了,则需要Full GC,然后判断释放的内存情况,然后增大或者减小这个数的大小,所以为了减少GC的次数,最好将这个数设置的大一点。
- 是方法区的一部分
- 常量池表示Class文件的一部分,用于存放编译器生成的各种字面量与符号引用,这部分内容将在类加载完成后存放在方法区的运行时常量池中。
- 运行时常量池相对于Class文件常量池的另一重要特征是:具备动态性。
String.intern()
首先明确,只有HotSpot有永久代,JRocket和J9是不存在永久代的。
HotSpot的变化:
JDK6及以前:方法区被称为永久代,静态变量存放在永久代上。
JDK7:方法区被称为永久代,静态变量和字符串常量池位于堆上。
JDK8:方法区被称为元空间,类型信息、方法、字段常量保存在本地内存的元空间上,静态变量和字符串常量池位于堆上。
- 为永久代设置空间大小是很难确定的
- 对永久代进行调优是困难的
JDK7中将字符串常量池放进永久代,因为永久代的回收率低,只有Full GC的时候才会触发。
导致StringTable回收率不高,但是我们开发中会有大量的字符串被创建,回收率低,导致永久代空间不足。
放到堆中,可以及时回收内存。
《Java虚拟机规范》并没有明确方法区要不要回收,所以回不回收要看具体的虚拟机。
方法区的回收一般是:常量池中废弃的常量和不再使用的类型。
创建对象共有六步:
-
判断对象对应的类是否加载、链接、初始化
虚拟机遇到一个new指令,首先去建成这个指令的参数能否在元空间的常量池中定位到一个类的符号引用,并且检查符号引用代表的类是否被加载、链接、初始化。(即判断类信息是否存在)。如果没有,则在双亲委派模式下,使用当前类加载器查找.class文件,如果未找到,则抛出
ClassNotFoundException
异常,如果找到,则进行类加载,并生成对应的Class对象。 -
为对象在堆区分配内存空间(如果是内存规整,则是用指针碰撞,如果内存不规整,则使用空闲列表)
-
处理并发安全问题(使用CAS加失败重试的机制保证内存分配的原子性,或者直接在每个线程分配的TLAB中实例化对象,不需要考虑并发安全问题)
-
初始化对象默认值(零值初始化)
-
设置对象头的信息(对象头中包含对象类元信息、HashCode值、GC年龄、锁信息等)
-
执行对象的
<init>
方法,对对象中的属性或引用进行显式赋值。
- 对象头
- 运行时元数据:HashCode值,GC年龄,锁信息,偏向线程ID等
- 类型指针,指向类元数据,确定该对象所属的类型。
- 实例数据
- 存储对象中真正的有效信息,包括对象中定义的各种类型的字段
- 对齐填充,是对象的内存空间的大小是8字节的整数倍。
有两种:句柄访问,直接指针(HotSpot使用的就是直接指针)
判断垃圾的方法总共有两种:
- 引用计数算法,对象存储一个引用计数器,对象被引用一次,就+1,取消引用一次,就-1,当对象的引用计数器为0时,此对象就是垃圾。
- 优点:简单,方便
- 缺点:有循环引用的致命性缺点。
- 可达性分析算法
- 从GC Roots集中向下遍历,未遍历到的对象就被视为垃圾。
对象的finalize()
方法
当垃圾回收器发现没有引用指向一个对象时,首先会调用对象的finalize()
方法,这个方法用于在对象被回收时进行资源释放,永远不要主动调用对象的finalize()
方法,由于finalize()
方法存在,所以对象有三种状态:可触及的,可复活的,不可触及的三种。
-
首先使用可达性分析算法标记所有被引用的对象,一般是在对象头中记录为可达对象。
-
然后对堆内存从头到尾线性遍历,如果发现某个对象在其对象头中未标记为可达对象,则将其回收。
缺点:
- 效率不算高
- 在进行GC时,需要停止整个应用程序,导致用户体验差。
- 这种方式清理出来的内存是不连续的,会产生大量的空间碎片,需要维护一个空闲列表。
将内存分为两块,每次只使用其中一块,当进行垃圾回收时,将存活的对象直接复制到另一块空闲空间,完成之后,直接将之前一整块空间进行回收。然后交换两个内存的角色。
优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去以后保证了空间的连续性,不会出现“碎片“问题。
缺点:
- 需要两倍的内存空间
- 需要维护对象的引用关系,因为是复制,所以需要修改引用的地址。
- 第一步跟标记-清除一样,是标记上存活的对象
- 然后将存活的对象压缩到内存的另一端,按顺序排放
- 之后,清理边界外的内存空间
优点:
- 消除了内存碎片的缺点,JVM只需要持有一个内存的起始地址即可。
- 消除了复制算法中,内存减半的缺点。
缺点:
- 效率是三种算法中最低的。
- 移动对象,还需要调整对象的引用地址
- 移动过程中,需要全程暂停用户线程。
不同生命周期的对象采用不同的垃圾收集算法,以便提高回收效率。
Java一般是把堆分成老年代和新生代
新生代的对象77%~99%都是“朝闻夕死”的,所以回收次数比较多,所以采用复制算法。
老年代大部分对象不会被回收,所以不适合使用复制算法,Java中的HotSpot对老年代使用CMS垃圾收集器,使用的是标记-清除算法。当内存回收不佳时,会使用Serial Old收集器对老年代进行回收,使用的标记-整理算法。
增量收集算法:每次只收集一部分垃圾,直至收集完毕。用户线程和垃圾收集线程来回切换。
分区算法:为了更好的控制GC产生的停顿时间,将内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干小区间,而不是整个区间,减少GC停顿的时间。每个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。