研究
JVM
首先要知道它是做什么的,它是一个虚拟机,我们写的java
程序需要经过编译后生成class
文件,在JVM
内完成执行,JVM
是如何做到这些的?它包含哪些结构?里面的一些机制是怎么实现的?最近学习了一点皮毛,打算记录下
JVM的基本结构
结构来看,执行我们的代码需要从编译我们写的Java代码开始,一直到类加载器再到JVM内部完成程序的执行,中间包含了许多的细节
-
类加载器,链接,初始化
- 双亲委派机制
-
沙箱机制
- GC 垃圾回收
- JNI
- 堆栈内存
- 程序计数器
沙箱机制(类的装载阶段)
我们的写的程序被编译后,需要通过类加载器完成类的创建,由反射机制可知,对象可以有多个但是他们的类始终都是那一个,也就是可以理解为在内存中存在一个类的“对象”,通过这个类的”对象“来创建一个一个的对象,这个对象是如何创建的?答案就是通过类加载器
在java中存在三个类加载器,分别是
- 应用程序类加载器(Application)
- 扩展类加载器(Extension)
- 启动类加载器(Bootstrap)
加载类的步骤大致是这样的
可以看到实例化的对象在栈中存在一个引用,并且可以通过反射的方法来获取对象的信息
沙箱机制描述
沙箱机制是Java安全模型的核心机制,它限制程序对系统资源的访问,包括CPU,内存,文件系统,网络等等。通过将代码限制在特定范围内执行,保证了整个系统的安全性
它包括字节码校验器,以及类加载器,以此实现Java体系的安全
字节码校验器
在编译一个Java文件时,字节码校验器会对其进行检查,如果不符合Java规范,会直接编译失败,但是核心类不会经过字节码的校验
类加载器和双亲委派机制
在类加载器加载类时,会逐级上探,如果高一级的类加载器可以加载的类,就让高级的加载器去加载,最后加载不了的才用低级别的加载器,这样可以保证Java体系的安全性
-
Java的根加载器(BootStap)是使用C++编写,并随着JVM的启动而启动,通过它加载的类,我们通过反射的方法得到的加载器类型为null,因为java无法识别这种类型,获取它的引用时,就只能返回一个null了
-
当Java虚拟机要加载一个类时,到底派出哪个类加载器去加载呢?
-
首先当前线程的类加载器去加载线程中的第一个类(假设为类A)。 注:当前线程的类加载器可以通过
Thread
类的getContextClassLoader()
获得,也可以通过setContextClassLoader()
自己设置类加载器。 -
如果类A中引用了类B,Java虚拟机将使用加载类A的类加载器去加载类B。
-
还可以直接调用
ClassLoader.loadClass()
方法来指定某个类加载器去加载某个类。
-
类加载时通过这样的机制,可以保证Java的重要部分不会被恶意改动,保护代码,比如String类、Object类,通过任何的设置,最终都一定是由根加载器来加载
通过这样的机制,保证同一个类仅被加载一次,当一个类第二次被加载时,之前加载过它的类加载器就会检查到这一情况,直接返回第一次加载的”对象“,以此保证一个类仅会被加载一次
自定义的类加载器
同一个class文件,通过指定被两个不同的加载器加载后,他们也不是同一个类了
这是因为虚拟机对每一个类加载器加载的类维护一个命名空间,命名空间由一系列的唯一名称组成,它们互相之间是不可见的,虽然是同一个类,但由不同类加载器加载后,它们被赋予了不同的“名字”
Tomcat定义了多个不同的类加载器,做到了以下几点
- 保证了同一个服务器的两个Web应用程序的Java类库隔离;
- 保证了同一个服务器的两个Web应用程序的Java类库又可以相互共享;比如多个Spring组织的应用程序不能共享,会造成资源浪费;
- 保证了服务器尽可能保证自身的安全不受不受部署Web应用程序影响;
- 支持JSP应用的服务器,大多需要支持热替换(HotSwap)功能。
存取控制器
存取控制器用以控制类对核心API对操作系统的存取权限,这个控制策略设定,可以由用户设置
安全管理器
安全管理器是核心API和操作系统的主要接口,作用是实现对类的权限控制,他的权限比存取控制器更高
安全软件包
可以让我们对对应用提供额外的安全特性,包括
- 安全提供者
- 数字签名
- 加密
- 鉴别
- 消息摘要
Native关键字和方法区
Native关键字
当出现这个关键字时说明Java无法完成这一操作,需要调用底层的C库,这些方法将会进入本地方法栈(Native Method Stack),
在执行引擎执行时,调用本地的库
比如以下应用
- 线程
- 操作硬件(打印机、鼠标、键盘等)
Java通过调用JNI(Java Native Interface)来扩展Java类的使用,可以融合其他编程语言为Java所用
方法区
方法区是一个概念,存储类信息、常量池、静态变量、JIT编译后的代码等数据,它被所有的线程共享,方法区属于共享区间
静态变量(Static)、常量(final)、类信息(构造方法和接口定义)、运行时的常量池(字符串)都存在方法区中。
要注意,实例变量存储在堆内存中,和方法区无关
PC寄存器
每个线程都有一个程序计数器,实际上是很小的一部分,它是线程私有的,是一个指针,指向了方法区的方法字节码(存储指向下一条指令的地址),会在执行引擎中读取下一条指令
栈内存
栈内存是一种先进后出,后进先出的结构,主管程序的运行,程序的生命周期,线程同步,当线程结束后,栈内存也被清空,因此也不存在垃圾回收的问题
栈中存储基本类型,对实例对象的引用和实例的方法
栈帧
程序每执行一个方法(执行一步)都会产生一个栈帧,正在执行的方法会在栈的顶部,每一个栈帧包含对上一个栈帧的关联信息、方法内容、需要的变量以及引用等等,当栈帧的叠加超过了栈的高度,就会抛出栈溢出错误(StackOverFlowError),导致程序的崩溃
堆内存
一个JVM仅有一个堆内存,其大小是可以调节的
在类加载器加载完毕后,会把类对象、方法、变量、常量等等一些需要引用的对象放到堆内存中,保存引用类型的真实对象;
堆内存中分为三个区域
新生代(伊甸园区)
包括Eden区、ServivorFrom、ServivorTo三个区
- Eden区,大约占堆内存的三分之一,新生的对象会被创建到此位置,但如果对象很大,会被直接创建到老年代,当Eden区接近满载时,会触发MinorGC垃圾回收
- ServivorTo:保留了一次MinorGC过程中的幸存者
- ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者
MinorGC
-
MinorGC采用复制算法
-
把Eden和ServivorFrom区域中存活的对象复制到ServicorTo区域(如果有对象的年龄以及达到了老年的标准(可调节),则赋值到老年代区),同时把这些对象的年龄+1(如果ServicorTo不够位置了就放到老年区)
-
清空Eden和ServicorFrom中的对象
-
ServicorTo和ServicorFrom互换,原ServicorTo成为下一次GC时的ServicorFrom区
老年代
存放生命周期较长的对象,当老年代完全满了之后会触发OOM(OutOfMemoryError)错误
OOM错误并不会经常发生,一般情况下在新生代MinorGC触发后,进入老年代的对象过大时会触发MajorGC来清理空间
MajorGC
首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象
优点:不需要额外空间
缺点:
-
MajorGC的耗时比较长,因为要扫描再回收
-
MajorGC会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配
当触发MajorGC也没用时,内存被撑爆了,就会抛出OOM(OutOfMemoryError)错误
永久代(元空间)
内存的永久保存区域,存储元数据和Class,GC不会对此区域进行清理,因此此区域会随着Class的加载变得越来越多,最终可能抛出OOM(OutOfMemoryError)错误
在JDK1.8以后,永久代被移除,变成了元空间,区别在于元空间并不在虚拟机中了,使用本地内存,因此它只受限于本地内存的大小,类的元数据放入本地内存, 字符串池和类的静态变量放入Java堆中. 这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制
采用元空间而不用永久代的几点原因:
- 为了解决永久代的OOM问题,元数据和class对象存在永久代中,容易出现性能问题和内存溢出
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出(因为堆空间有限,此消彼长)
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低
- Oracle 可能会将HotSpot 与 JRockit 合二为一
永久代和方法区的关系
-
Java 6中,方法区中包含的数据,除了JIT编译生成的代码存放在native memory的CodeCache区域,其他都存放在永久代
-
Java 7中,Symbol的存储从PermGen移动到了native memory,并且把静态变量从instanceKlass末尾(位于PermGen内)移动到了java.lang.Class对象的末尾(位于普通Java heap内)
-
Java 8中,永久代被彻底移除,取而代之的是另一块与堆不相连的本地内存——元空间(Metaspace),‑XX:MaxPermSize 参数失去了意义,取而代之的是-XX:MaxMetaspaceSize
出现OOM错误后该怎么调试
- 可以先通过调节堆内存并在控制台打印出内存的信息,参数为
-XX:+printGCDetails
,快速检查是不是因为内存不足 - Dump内存文件进行分析,参数为
-XX:+DumpOnOutOfMemoryError
,生成hprof
文件 - 使用内存快照分析工具分析Dump内存文件,快速定位内存泄漏,比如MAT,Jprofiler,这些工具可以方便的完成这些事情
- 查看占用较大的对象
- 查看引用的对象
- 查看线程信息
- 查看CPU信息
- 获得堆中的数据
- 等等……
- 经过分析后,再对代码进行针对性的修改,达到调试目的
GC算法
引用计数法(JVM未采用)
每一次调用后都对对象进行计数+1,调用失效后就-1,计数为0的对象会被清理
优点包括:
- 实现简单
- 判断高效
缺点是存在判断逻辑的错误,无法解决对象间互相循环引用的问题
MinorGC(复制算法)
-
首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象
-
MajorGC的耗时比较长,因为要扫描再回收
-
MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配
当触发MajorGC也没用时,内存被撑爆了,就会抛出OOM(OutOfMemoryError)错误
这种算法优点是没有内存碎片产生,但是造成了内存空间的浪费,有一部分内存空间是空置的
MajorGC(标记清除算法)
首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象
优点:不需要额外空间
缺点:
-
MajorGC的耗时比较长,因为需要扫描两遍
-
MajorGC会产生内存碎片
MajorGC的优化,标记压缩算法
可以再次进行扫描对象,达到清理内存碎片的目的,但是会产生多余的移动操作
GC算法总结
内存效率:复制算法 > 标记清除算法 > 标记压缩算法 内存整齐度:复制算法 = 标记压缩算法 > 标记清除算法 内存利用率:标记压缩算法 = 标记清除算法 > 复制算法
由于各种算法存在着不同的缺陷,GC使用分代收集算法
- 在新生代使用复制算法
- 在老年代混合使用标记清除和标记压缩算法
参考:
Java8内存模型—永久代(PermGen)和元空间(Metaspace)