JVM
三种JVM
- Sun公司 Hotspot
- BEA JRockit
- IBM J9 VM
jvm的体系结构简图
java栈、本地方法栈、程序计数器不会存在垃圾,所以不会有垃圾回收。垃圾回收存在于堆和方法区中。
类加载器
作用:加载class文件
加载器的分类:
- 虚拟机自带的加载器
- 启动类加载器(根加载器)
- 扩展类加载器
- 应用程序加载器(系统加载器)
双亲委派机制
- 类加载器收到类加载的请求
- 将这个请求向上委托给父类加载器去完成,以此类推,直到根类加载器
- 根类加载器检查是否能够加载当前这个类,能加载就结束机制,使用当前的加载器。否则抛出异常,通知子加载器进行加载。
- 重复步骤3
作用:防止恶意的代码去干涉正常的代码
沙箱安全机制
java安全模型的核心就是java沙箱(sandbox),什么是沙箱?沙箱是一个限制程序运行的环境。沙箱机制就是将java代码限定在虚拟机特点的运行范围中,并且严格限制代码对本地系统资源的访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。沙箱主要限制系统资源访问,系统资源包括:CPU、内存、文件系统、网格。不同级别的沙箱对这些资源的访问限制也可以不一样。
所有的java程序运行都可以指定沙箱,可以定制安全策略。
在java中将执行程序分为本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看做是不受信任的。对于授信的本地代码,可以访问一切本地资源。而对于非授信的远程代码在早期的java实现中,安全依赖于沙箱机制。
当前最新的安全机制实现,则引入了域(Domain)的概念。虚拟机会把所有代码加载到不同的系统域和应用域,系统域专门负责与关键资源进行交互,而各个应用域则通过系统域的部分代理来对各种需要的资源进行访问。虚拟机中不同的受保护域,对应不一样的权限。存在于不同域中的类文件就具有了当前域的全部权限。如下图所示,最新的安全模型(jdk1.6)一直沿用
组成沙箱的基本组件
- 字节码校验器:确保java类文件遵循java语言规范。这样可以帮助java程序实现内存保护。但并不是所有的类文件都会经过字节码校验,比如核心类
- 类加载器:其中类加载器在3个方面对java沙箱起作用
- 它防止恶意的代码去干涉正常的代码。双亲委派机制
- 它守护了被信任的类库边界
- 它将代码归入保护域,确定了代码可以进行哪些操作
- 存取控制器:存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定
- 安全管理器:是核心API和操作系统之间的主要接口。实现权限控制,比存取控制器优先级高
- 安全软件包:java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括
- 安全提供者
- 消息摘要
- 数字签名 keytools
- 加密
- 鉴别
Native
凡是带了native关键字的,说明java的作用范围达不到了,需要去调用底层C语言的库。会进入本地方法栈,调用本地方法接口(JNI:Java Native Interface),然后通过本地方法接口去实现扩展。它可以调用不同的语言为java所用
本地方法栈的作用:登记native方法
native一般用于与硬件或操作系统打交道。在企业级应用中很少见
PC寄存器
程序计数器:每一个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向一条指令的地址,也即将要执行的指令代码),在执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计
方法区
Method Area 方法区
方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊的方法,比如构造函数,接口代码也在此定义,简单来说,所有定义的方法的信息都保存在该区域,此区域属于共享区间;
静态变量、常量、类信息(构造方法、接口定义)、运行时的常量池都存在方法区中,但是实例变量存在堆内存中,和方法区无关(static、final、Class、常量池)
栈
栈是一种数据结构。它的特点是:先进后出,后进先出。它主管程序的运行。它的生命周期和线程同步。对于栈来说,不存在垃圾回收问题
栈里面存放的东西:8大基本类型、对象的引用、实例的方法
堆
Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。堆里面存放对象的具体实例、以及它的一些方法、变量、常量等,它保存我们所有引用类型的具体实例。
堆内存中细分为三个区域:
- 新生区(伊甸园区) Young
- 老年区 Old
- 永久区 Perm
jdk1.6之前堆内存简图:
GC垃圾回收,主要是在伊甸园区和老年区
假设堆内存满了,就会报OOM错误:java.lang.OutOfMemoryError: Java heap space
在JDK8以后,永久区改名为元空间
新生区
新生区是类诞生甚至死亡的地方
新生区划分:
- 伊甸园区:所有的对象都是在伊甸园区new出来的
- 幸存者区(0、1):在伊甸园区经过GC处理后幸存下来的对象,存放在幸存者区
在java中,大部分的对象都是临时对象,因此它们一般都活不到幸存者区
永久区(永久代)
这个区域是常驻内存的,用来存放一些jdk自身携带的Class对象、Interface元数据,存储的是java运行时的一些环境或类信息,这个区域不存在垃圾回收。关闭VM虚拟机就会释放这个区域的内存。
什么时候永久区会挂掉呢:一个启动类加载了大量的第三方jar包。Tomcat部署了太多的应用、大量动态生成的反射类。这些东西不断的被加载,直到内存满,就会出现OOM
- jdk1.6之前:永久代,常量池是在方法区
- jdk1.7:永久代,但是慢慢的退化了,提出“去永久代”概念。常量池在堆中
- jdk1.8之后:无永久代,常量池在元空间中
jdk1.8之后的堆内存简图:
以上这种内存图划分中的元空间在逻辑上存在,但是在物理上不存在
堆内存调优
在java.lang包下有一个Runtime类,我们可以通过它来调优Java运行时环境,可以通过它来获取JVM的内存使用大小:
//返回虚拟机试图使用的最大内存,以字节为单位
long max = Runtime.getRuntime().maxMemory();
//返回JVM的初识化的总内存,以字节为单位
long total = Runtime.getRuntime().totalMemory();
在默认情况下,JVM虚拟机分配的总内存是电脑内存的1/4,JVM初始化的总内存是电脑内存的1/64。
我们可以修改参数来修改这些内存:-Xms1024m -Xmx1024m -XX:+PrintGCDetails
-
-Xms1024m:设置JVM初始化内存大小
-
-Xmx1024m:设置JVM占用总内存大小
-
+PrintGCDetails:在控制台打印GC垃圾回收的信息
OOM错误如何解决:
-
尝试扩大堆内存看结果
-
分析内存,看那些地方出现了问题(内存快照分析工具:MAT、Jprofiler)
-
内存快照分析工具的作用:
- 分析dump内存文件,快速定位内存泄漏问题
- 获得堆中的数据
- 获得大的对象
- …
注意OOM是Error不是Exception,捕获的时候需要使用Error对象。我们可以在出问题的代码上加上参数:
- -Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
- 其中Xms是指定JVM初始化的内存大小,Xmx是指定JVM总内存的大小。
- XX:+HeapDumpOnOutOfMemoryError:是当堆内存中出现OOM错误时会打印出一个相关Dump文件,此时可以使用Jprofiler工具打开该文件,排查错误。
GC垃圾回收
GC垃圾回收只作用在堆和方法区中,而方法区也在堆里。
众所周知,堆分为三个区域:
-
新生代
- 伊甸园区
- 幸存者0区
- 幸存者1区
-
老年代
-
元空间
JVM在进行GC时,并不是对这三个区域进行统一回收。大部分的时候都是在新生代里进行垃圾回收
GC垃圾回收分为两种:
- 轻GC(普通GC):只作用于新生代中
- 重GC(全局GC、Full GC):作用于老年代中
常见的GC垃圾回收算法
引用计数法
给堆中的对象都都加上一个计数器,每当对象被引用一次,对应的计数器就会自增,然后GC垃圾回收根据计数器的数值来清理掉那些不被调用的对象。因为给对象加上计数器本身就会有消耗,而且有时候项目中的对象数量也非常多,这样的话,消耗就比较大,效率低。因此在Java中很少使用
复制算法
- 在堆内存中,每次GC垃圾回收之后,伊甸园区活着的对象就会被转移到幸存者to区,因此伊甸园区被GC之后就是空的
- 幸存者区分为from区和to区,这两个区是不断相互转化的,谁空谁就是to区,当伊甸园区将活着的对象转移到to区,就会使用复制算法将幸存者from区中的对象也转移到幸存者to区中,此时重新划分from区和to区,to区变成了from区,from变成了to区,用来保障to区一直是空的
- 当新生代中的对象经历了15次GC还没有死亡时,他就会进入到老年代。我们可以通过一个参数来设置进入老年代需要经历多少轮GC,默认是15次
- -XX:MaxTenuringThreshold=15
新生代的GC主要用的是复制算法。复制算法的最佳使用场景:对象存活度较低。刚好新生区的存活度低,因此,复制算法主要用于新生区中
- 好处:没有内存的碎片,它会使堆中的对象集中在某个区域中。
- 坏处:浪费了一个幸存者区的内存空间,这个幸存者区的空间永远是空的
标记清除算法
标记清除算法见名知意,分为两步:
- 标记:将活着的对象作上标记
- 清除:将没有标记的对象进行清除
- 优点:不会浪费额外的空间
- 缺点:两次扫描严重浪费时间,会产生内存碎片
标记压缩算法
标记压缩是对标记清除的优化。标记清除会产生内存碎片,对象存放的位置很零散。而标记压缩在标记清除的基础上,做了一个压缩的步骤,解决内存碎片的问题,使存活的对象集中在一处,其他的则是空的并且连续的空间。
但是显而易见,标记压缩算法也有弊端,它虽然是标记清除的优化,但是它也多出了一个移动对象的成本
其实这种算法也可以做一些优化,就是多清除几次,然后执行一次压缩,减少压缩的次数,减轻移动的成本
总结
通过三个方面来对这几种算法进行排序
内存效率(时间复杂度):复制算法>标记清除算法>标记压缩算法
内存整齐度(空间复杂度):复制算法=标记压缩算法>标记清除算法
内存利用率:标记清除算法=标记压缩算法>复制算法
从以上可以看出,这几种算法都各有优劣,难道没有最优的垃圾回收算法吗?
答案:没有。没有最好的算法,只有最合适的算法
所以,GC:分代收集算法
新生代:存活率低,对内存利用率要求不高,所以使用复制算法最合适。
老年代:存活率高,区域大,使用标记清除+标记压缩算法混合实现最优