目录
本文章是根据《深入理解Java虚拟机》一书,并参考网上其他文档进行的系统性的和简单容易理解的方式进行的整理。
程序员常常说软件不行,硬件来凑。但是针对当前对系统高可用、高并发的需求日益增大,需要程序员写出更稳定、性能更好的应用程序,开发人员需要了解虚拟机的运行原理,才会写出最适合虚拟机运行的代码。Java虚拟机是中高级开发人员必须修炼的知识。
正是由于Java虚拟机的存在,才能使Java开发的应用程序能够一次开发,处处运行。
一、Java内存区域
1、程序计数器:较小的内存空间,当前线程所执行的字节码文件的行号指示器,线程私有的。
2、Java虚拟机栈:用于存储方法执行时的局部变量表、操作栈、动态链接、方法出口等信息,线程私有的。方法开始执行到执行结束对应着入栈与出栈的过程。
3、本地方法栈:类似于Java虚拟机栈,Java虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用到的Native方法服务。线程私有的。
4、堆:线程共享的,存放对象实例。堆是垃圾回收管理的主要区域。需要更好的分配内存和更快的回收内存,如分代收集算法。并不是所有的对象和数组,都是在堆上进行分配的,由于即时编译的存在,如果JVM发现某些对象没有逃逸出方法,就很有可能被优化成在栈上分配。
5、方法区(永久代):1.8版本之前。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译的代码等。线程共享的。需要进行垃圾回收。1.8版本之后会放到本地内存中,命名为元数据区,只要内存足够则不会内存溢出。
6、运行时常量池:存在于方法区(1.8之前)或元数据区(1.8及之后),用于存放编译时类中的常量或程序运行时的常量。
内存溢出只会在栈、堆、方法区中产生,当程序发生内存溢出时需要能够及时判定内存溢出的位置。
java中通过栈中的引用变量访问对象的方式有两种,一个是通过句柄池的方式,一种是通过指针的方式。通过句柄池的方式有助于在垃圾回收移动对象时只修改句柄池的信息,而不需要修改栈中的指针,但是通过句柄池的方式访问对象会比指针直接访问的速度要慢。Hotspot JVM采用指针直接访问的方式。
二、内存溢出
1、内存泄漏(memory leak):当一个对象已经不需要再使用本该被回收时,另外一个正在使用的对象持有它的引用从而导致它不能被回收,这导致本该被回收的对象不能被回收而停留在堆内存中,这就产生了内存泄漏。
2、内存溢出(out of memory):程序在申请内存时,没有足够的内存空间供其使用,出现 out of memory。内存泄露是导致内存溢出的主要原因。
3、Java堆溢出:是最常见的内存溢出现象。当出现堆内存溢出时需要先对代码进行分析,分析是否存在内存泄漏情况。
4、栈内存溢出:虚拟机在扩展栈时申请不到足够的内存空间,则会抛出栈溢出。单个线程下,无论是栈帧太大还是虚拟机容量太小,当内存无法分配时则会造成栈内存溢出。当线程数异常增加也会造成栈溢出。Hotspot的虚拟机栈和本地方法栈是同一个。
5、运行时常量池溢出:程序运行时向常量池中增加了太多的常量导致内存溢出。在1.8及以后的版本中,因为方法区变成了元数据区,此时运行时常量池溢出不容易发生。
6、方法区溢出:加载了过多的字节码文件,如使用Spring框架的Cglib代理技术时。在1.8及以后的版本中,因为方法区变成了元数据区,此时方法区溢出不容易发生。
三、垃圾收集算法
1、可回收的对象
①引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它时计数器就加1,反之如果引用失效时计数器值就减1。此算法的主要问题是无法解决循环引用的问题,如objA = objB;objB = objA;此时程序执行一次就不在执行了,但是却无法回收对象。
②可达性分析算法(根搜索算法):通过一系列”GC Roots“的对象作为起始点,从这些起始点向下搜索时所走过的路径称为引用链。当一个对象到”GC Roots“对象没有任何引用链时则证明对象是不可用的,即这个对象到“GC Roots”不可达,可被回收。
在Java中可作为GC Roots的对象包括:
- 虚拟机栈中的引用的对象;
- 类变量引用的对象;
- 常量引用的对象;
- 本地方法栈中引用的对象;
2、引用类型
堆中存储的是引用类型实例,基本数据类型的大小是固定的(基本类型的包装类型的大小),但是引用类型的大小的则不是固定的。
Java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用。
强引用:一般是声明对象时虚拟机生成的引用,垃圾回收时需要严格判断当前对象是否被强引用,如果被强引用,则不会被垃圾回收。
软引用:软引用一般被做为缓存来使用。与强引用的区别是,软引用在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间,如果剩余内存相对富裕,则不会进行回收。虚拟机在发生OutOfMemory时,肯定是没有软引用存在的。
弱引用:弱引用与软引用类似,都是作为缓存来使用。但与软引用不同,弱引用在进行垃圾回收时,是一定会被回收掉的,因此其生命周期只存在于一个垃圾回收周期内。
虚引用:顾名思义就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。 虚引用主要用来跟踪对象被垃圾回收器回收的活动。
我们系统一般在使用时都是用的强引用。而“软引用”和“弱引用”比较少见。他们一般被作为缓存使用,而且一般是在内存大小比较受限的情况下做为缓存。
3、垃圾收集算法
通过引用类型和可回收对象算法判定了那些对象是可以回收的,接下来需要确定的就是通过哪一种算法来进行垃圾回收,从而找到一个速度快、又节约内存的垃圾收集算法。
①标记-清除算法(Mark-Sweep):此算法执行分两阶段。第一阶段从引用根节点开始标记所有被引用的对象,第二阶段遍历整个堆,把未标记的对象清除。此算法需要暂停整个应用,同时,会产生内存碎片。最基础的算法。
②复制算法(Copying):基于标记-清除算法,此算法把内存空间划为两个相等的区域,每次只使用其中一个区域。垃圾回收时,遍历当前使用区域,把正在使用中的对象复制到另外一个区域中。此算法每次只处理正在使用中的对象,因此复制成本比较小,同时复制过去以后还能进行相应的内存整理,不会出现“碎片”问题。此算法的缺点也是很明显的,就是需要两倍内存空间。
③标记-整理算法(Mark-Compact):此算法结合了“标记-清除”和“复制”两个算法的优点。也是分两阶段,第一阶段从根节点开始标记所有被引用对象,第二阶段遍历整个堆,清除未标记对象并且把存活对象“压缩”到堆的其中一块,按顺序排放。此算法避免了“标记-清除”的碎片问题,同时也避免了“复制”算法的空间问题。
④分代收集算法:虚拟机中的共划分为三个代:年轻代(Young Generation)、老年代(Old Generation)和持久代(Permanent Generation,1.8版本后是MetaData)。其中持久代主要存放的是Java类的类信息,与垃圾收集要收集的Java对象关系不大。年轻代和年老代的划分是对垃圾收集影响比较大的。
年轻代:所有新生成的对象首先都是放在年轻代的。年轻代分三个区,一个Eden区,两个Survivor区(一般而言)。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。Survivor的两个区是对称的,且Survivor区总有一个是空的,根据程序需要,Survivor区是可以配置为多个的(多于两个)。一般使用复制算法。
年老代:在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。一般使用标记-整理算法。
持久代:用于存放静态文件,如今Java类、方法等。持久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如Hibernate等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。持久代大小通过-XX:MaxPermSize=进行设置。(1.8版本及以上不考虑)
四、垃圾收集器
垃圾收集器是在通过垃圾回收算法确定了采用的方式之后确定怎么来实现垃圾回收的实现方式,即是垃圾回收算法的具体实现。没有万能的垃圾收集器,不同业务场景需要不同的垃圾收集器,常见垃圾收集器如下图所示:
如果两个垃圾收集器之间存在连线则是可以混合使用。
1、Serial收集器:用于年轻代、单线程、复制算法、最悠久的垃圾收集器。执行时会在后台暂停其他所有的线程(stop the world),如果执行时间过长则会带来用户体验不好。
场景:Serial垃圾收集器是程序运行在Client模式下的JVM的一个最好选择,也是此模式下默认的年轻代垃圾收集器。如下图所示:
2、ParNew收集器:用于年轻代、多线程(并行)、复制算法。Serial收集器的多线程版本,其他特性与Serial收集器基本一致。其在单CPU的环境不会比Serial收集器性能好。
场景:适用于多CPU场景,默认线程数量与CPU数量一致。
3、Parallel Scavenge垃圾收集器:用于年轻代、多线程、复制算法、吞吐量优先的垃圾收集器。
场景:主要用于控制吞吐量的垃圾收集器,保证最大的吞吐量,适用于后台计算较多而用户交互较少的场景。
PS:吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
4、Serial Old垃圾收集器:老年代、单线程、标记-整理算法。是Serial收集器的老年代版本。
场景:Serial Old垃圾收集器是程序运行在Client模式下的JVM的一个最好选择。
5、Parallel Old垃圾收集器:老年代、多线程、标记-整理算法。是Parallel Scavenge收集器的老年代版本。
场景:适用于多CPU场景。
6、CMS垃圾收集器:老年代、多线程、标记-清除算法。获取最短回收停顿时间为目标的收集器,适合互联网网站和B/S系统的服务端。
缺点:
①会占用部分CPU资源导致应用程序变慢,从而导致总吞吐量降低。
②默认启动的线程数是(CPU数量+3)/4个,占用资源较高。
③因为使用标记-清除算法,会在老年代产生碎片,更容易再次触发垃圾回收。
④因为进行垃圾收集的时候用户程序仍在运行,所以会在垃圾收集期间继续产生垃圾对象,从而导致垃圾回收会在之后再次触发。
场景:适合互联网网站和B/S系统的服务端。
7、G1收集器:支持在年轻代和年老代收集、支持并发、采用标记-整理算法。对CMS收集器的改良,在不降低吞吐量的同时还能减少与用户交互的停顿。原理是将堆划分为多个独立的区域,每次根据允许的回收时间回收垃圾最多的区域。
垃圾回收其他概念:
1、Minor GC:新生代(新生代分为一个 Eden区和两个Survivor区)的垃圾收集叫做 Minor GC。当新对象生成,并且在Eden申请空间失败时,就会触发Minor GC,对Eden区域进行GC,清除非存活对象,并且把尚且存活的对象移动到Survivor区。因为对象一般是朝生夕灭的场景,所以Minor GC十分平常,一般回收速度也比较快。
2、Major GC/Full GC:年老代的GC,一般要比年轻代的GC慢10倍以上。当年老代内存空间不足时会触发一次Full GC。
①当Java虚拟机遇到需要大量内存的大对象时会直接存入年老代,但是又比较短命的这种大对象应该在写程序时尽量避免(避免在年轻代使用复制算法进行GC时中有大量的复制操作)。
②长期存活的对象将会被放入年老代;
垃圾收集器参数设置参考网址:https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
参考汇总:
1、https://blog.youkuaiyun.com/CrankZ/article/details/86009279;
2、https://blog.youkuaiyun.com/Simon111Qiu/article/details/109400350;