前言
作为一个Java程序员,JVM(Java虚拟机,下简称虚拟机)将执行我们编写的代码,并搞定包括内存管理之类的工作,可以说虚拟机是我们的服务提供商,我们是他的客户。从这个角度来说,虚拟机的架构和原理不是必须掌握的知识。然而总会有一些时刻会让我们恨不得把虚拟机扒个精光看透里面的所有花样,搞明白为什么我们的程序会出现诸如“OutOfMemoryError: ”、”StackOverFlow”的错误,或者为什么我们的程序就是那么慢,为什么就不能承受大流量的冲击呢。 本文是对本人对虚拟机的知识的一个总结,感谢以下几个信息来源:
- 《深入Java虚拟机》
- Java 内存模型:Infoq的迷你书
- Java深度探险:infoq的迷你书
- https://docs.oracle.com/javase/specs/jvms/se7/html/index.html: java官方文档
内容大致分为如下几个方面:
- 虚拟机内部架构和Java内存模型
- 虚拟机支持的特性,如多线程,并发控制,多态的实现、类加载
第一部分 虚拟机内部架构和Java内存模型
当我们谈论虚拟机的时候,被提到最多的一定是“xx区”、堆、栈等。这些概念的确是了解虚拟机的基础。准确地来说,这些是虚拟机的内存数据空间,包括如下逻辑上的区域:
- 堆
- 栈
- 本地方法栈
- 方法区
- 常量池
- 程序技术器
如下图所示:
(From: http://shyamalmadura.files.wordpress.com/2012/11/jvm_architecture3.png)
其中,“程序计数器(PC Register)”是提到较少的概念,其实主要用来记录当前线程的当前指令的内存地址,所以每个线程都会有一个对应的PC Register。 与PC Register相对应,栈主要记录方法的本地变量和计算的总结结果。由于Java栈只是个逻辑概念,所以其实际所使用的内存有可能也在堆中分配,并且Java栈的大小可以通过JVM启动参数来调节:
- -Xss:1024或
- -XX:ThreadStackSize=1024 (Sun 虚拟机专用)
当一个线程调用层级太多时,意味着有太多的本地变量和中间结果需要保存,Java栈会变得非常深,(通常来说Java栈的每一个元素(即栈帧)是固定大小的,忘了从哪里看来的了)当内存消耗超过上述参数指定的内存大小时,虚拟机会抛出StackOverflowError。有些虚拟机支持动态扩充栈的大小,所以可以缓解上述问题,但当虚拟机没有足够的内存给Java栈时,虚拟机会抛OutOfMemoryError,也即跟堆内存不够时同样的错误。
方法区主要保存虚拟机加载的类信息。这些类信息原本以Class文件的形式保存在文件中,虚拟机启动时或必要时(什么时候加载类信息留在后面说)会通过类加载机制将类信息放在内存中,而这块内存就叫做方法区。而方法区中的类信息是以对象的形式存在,即类信息也可以在程序中被应用,最常见的引用方式是:SomeClass.class.getClassLoader()。其中的SomeClass为类对象而不是实例对象,SomeClass.class就是这个类对象的一个指向自己类信息的静态变量。 除了类本身的信息,方法区还包括一个常量池(每个类都有一个),这些常量池通常是通过程序定义的final的变量值或编译器优化后的变量值(有些变量虽然没有指定定义为常量,但编译器通过优化算法发现其实该变量并不会被改动,也即是个常量)。 既然方法区也是一个内存区域,就必定存在内存不够的情况,而且这种情况通常发生在程序启动时(通过OSGI动态加载的模块也存在此风险)。比如tomcat里的webapp太多时(或一个webapp项目引入了太多的类),在启动时会抛:java.lang.OutOfMemoryError: Metaspace。 跟Java栈一样,方法区也可以实际由堆来分配内存,并且可以通过虚拟机启动参数设置大小:
- -XX:MetaspaceSize=xx 方法区大小设置
- -XX:MaxMetaspaceSize=30m 最大方法区大小设置
堆或者叫Java堆是开发人员打交道最多的,对象的创建、GC(垃圾收集)、内存异常(如OutOfMemoryError)都发生在这里。对象的创建没有太多花样,主要需要关注的是GC和GC引起的问题。 垃圾回收 垃圾收集是一个帮程序员“擦屁股”的机制,这个机制是Java和JVM上运行的其他语言的一大优势。但是也有这个”擦屁股“的机制都帮不上忙的时候,通常就会发生: java.lang.OutOfMemoryError: PermGen space 这样的错误。 顾名思义,上述错误中的PermGen space是java堆里的一个区域,除了这个区域,java堆中还有其他区域,如下图所示:
[From: https://autofei.files.wordpress.com/2012/04/jvm_model.jpg]
其中Permanent Space严格来说并不是java堆的组成部分,通常也不接受GC的管理。以上的划分并不是说将java堆物理地分成了三大块,而是GC程序,根据算法需要来划分的逻辑分区,即GC的基本思想是分代收集,分而治之。一个对象刚被创建的时候,是没有任何”信用“的,所以被放在GC监管很频繁的区域中:新生代。如果这个对象经过几轮洗礼,依然被证明是存活的,则表示这个对象足够老资格,会被转移到老年代中,老年代中的GC发生频率要小很多。 对象经历一次“洗礼”就是虚拟机运行一次GC收集操作,释放将死掉的对象(程序员只管生不管死,所以需要GC来收尸)所占用的内存。然而简单地“释放”内存还不够,否则会给java堆中留下太多内存碎片(还记得Windows的磁盘碎片整理吗?)不利于新对象的内存分配。虚拟机的应对策略是再将新生代分为三个区:Eden Space,Survivor Space(细分为From Space和To Space),新建对象时只放在Eden Space和From Space中,当GC发生时,将存活的对象(占总对象的比率通常很小)从这两个区复制到To Space,这样就把Eden Space和From Space给腾出来了,其中Eden Space还会继续接收新对象,而腾出来的From Space则会预留着给下次GC当”To Space“用。所以From Space和To Space只是相对的。 同样,这些空间的大小是可以通过虚拟机启动参数来调节的,比如:
- -Xmx: JVM最大可用内存大小
- -Xms: JVM初始内存大小
- -Xmn: 年轻代大小,Sun推荐为堆大小的3/8(并且持久代一般固定为64M,所以年轻代增大,则意味着年老代的减小)
- -XX:NewSize=N设置年轻代初始大小
- -XX:SurviviorRatio=N,年轻代中Eden大小和Survivor区大小的比,比如N=4,则两个Survivor和Eden大小比为2:4。
- -XX:NewRatio=n,年轻代和年老代的比值 讲到这里不得不提一下各种虚拟机参数的设置方式。通常的虚拟机参数有如下几种形式:
- -X[参数名]=[参数值] 标准的参数设置
- -XX:[参数名]=[参数值] 临时的(可能在新版本中有变更)参数设置
- -XX:[+|-][参数名] 通常用于Boolean类型的参数,比如:-XX:+DisableExplicitGC,就是设置参数DisableExplicitGC为True(因为默认是false),相反有些默认是true的参数就需要用减号来设置为false。
刚才提到GC发生时需要找出仍然活着的对象,但是如何在杂乱无章的对象海洋里找到仍然存活的对象呢?以前有个模糊的概念认为可以通过应用计数来实现:一个对象被应用了,则计数值加1,应用被解除则减1。但是实际上这个方案并不可行,比如两个(或更多)对象相互循环引用,即使他们都已经不会被使用,计数器的值仍不为0。虚拟机采用的方法是从一个根对象开始(根对象可以是一个类加载器,或者是虚拟机认为合适的对象,比如一个线程对象)根据引用链,一步步找到对象(应该是采用了图的查找算法)。这些能被找到的对象就是“存活的对象“,其他的对象就是“死对象”。 由于查找”死”对象的过程是一个耗时的工作,而且在查找过程中又有活对象不断“死去”,所以纯粹的垃圾回收的时候是不能有用户程序在运行的,但在实际的实现中,虚拟机在“时间停止“和”我的收集工作永远也没有尽头“之间做了一些平衡,不同的平衡的手段就产生了不同的垃圾回收算法,比如:
- Serial GC
- Parallel GC
- Concurrent Mark-Sweep GC (CMS)
- Garbage First GC
除了上述四个算法还有另外一个Parallel Scavenge,属于新生代垃圾回收算法。 Serial GC 已经很少用,目前只在客户端程序中做为默认的垃圾回收程序。而通常虚拟机通常都是以server模式运行,如果需要以客户端模式运行,则需要添加额外的参数:-client (-server为默认值)。至于client模式和server模式的区别,这个地址列了几个点:http://stackoverflow.com/questions/198577/real-differences-between-java-server-and-java-client 简单来说,区别在于-client注重减少启动时间和内存消耗,而-server则注重性能(通过编译优化,比如“aggressive inlining”)。我们能看得到的区别主要是默认的堆大小和GC算法。 Parallel GC则是server模式下默认的垃圾回收算法,顾名思义,这个算法支持多线程的垃圾回收。 CMS算法: 并发收集算法 Garbage Frist GC:并发收集,并且将待收集区域划分为跟小的区块进行收集。 可以通过如下参数设置虚拟机采用的垃圾回收算法:
- -XX:+UseSerialGC 使用串行垃圾回收算法
- -XX:+UseParallelGC 使用并行垃圾回收算法(年轻代)
- -XX:+UseParNewGC 设置年轻代并行回收(已经在Java5以后舍弃)
- -XX:+UseParallelOldGC 对老年代空间实行并行回收(默认为串行),Java6后才有
- -XX:+UseConcMarkSweepGC 打开并发垃圾回收算法
- -XX:+UseG1GC 使用G1(Java 7之后才有)
总的来说,垃圾回收算法通常尝试在这些方面进行努力:
- 并行还是串行
- 移除死对象还是保存”活“对象后销毁所有
- 不同的内存区域采用不同的收集策略
- 同一个区域进行分块收集
第二部分:虚拟机支持的编程特性
多线程和锁
程序员通过继承Thread类或者实现Runnable接口来实现多线程编程以充分利用多CPU的处理能力,这是程序员需要关心的,而剩下的则由JVM来实现。Java虚拟机为不同的线程创建不同的Stack 栈记录不同线程所需要的本地变量、操作数、中间结果等。线程对栈内数据的访问是安全的(逃逸对象除外),而对堆对象的访问则需要考虑同步问题,或者说线程安全问题:多个线程同时访问该对象,会不会产生与预期结果不一致的结果。这就是通常说的线程同步问题。 程序员通过Synchronized关键字指示虚拟机需要对指定区域的操作、数据采用同步机制:这些操作是互斥的。虚拟机通过程序代码指定的锁对象,对该对象的类信息的特殊字段设置标志位,指示其他线程等待该标志位的释放,并将等待的线程阻塞。 虚拟机中一个线程则对应操作系统的一个内核线程,线程的阻塞、唤醒等操作通过操作系统调用来实现,所以改变线程状态的操作通常是比较昂贵的,这也是为什么加了同步控制的多线程程序性能变慢,甚至发生1+1 < 2的情况。 为此虚拟机做了改进(1.5之后),改进围绕同步的实现手段进行:
- 减少互斥的命令范围:只在真正需要做同步的命令前后做锁定。所以预期对一个大方法设置synchronize,不如对方法内必须同步的代码块多同步。
- 缩小同步对象:某些容器对象内有很多元素,对整个对象加锁的效果和为了读/写一条数据而锁住整个数据表是一个道理。所以在这方面,可以将容器对象分割成不同的子对象,每次加锁只对操作目标所在的数据段加锁。这个方法的典型案例是ConcurrentHashMap
- 细化同步场景:类似于数据库的读写,读操作可以不对数据表加锁,写操作才需要。
- 多个锁合并成一个锁
- 乐观地不加锁,直到操作完成后:即使是写操作,也不应定会产生冲突,所以可以不加锁,而等到操作完成后判断是否跟预期的值一致来决定是否写操作被别的线程覆盖。
- 乐观地不枷锁,知道别的线程也来拿锁
- 锁了,但不阻塞线程:碰到锁之后不立即阻塞线程,而是执行一些空操作来消磨时间,之后在去判断下是否还能拿到锁。当然虚拟机自带的synchronize关键字没这功能。
- 根据上下文判断某些锁并没有意义,直接将锁给去掉。
根据这些办法,虚拟机提供了各种工具,作为synchronize关键字的备选(当然在虚拟机内部也采用了上述办法来优化锁,并且用法还是一样的简单):
- Reentranlock
- ReadWriteReentranlock
- Shemaphore:一个锁变成N个锁,锁用完了才需要阻塞线程
- Condition:这个更多的是从功能的角度而不是性能的角度,这可以让锁判断有多个维度,而不是简单的”是/否“
多态
Java的三大特征之一就是多态,Oracle的官方是这么定义多态的: The dictionary definition of polymorphism refers to a principle in biology in which an organism or species can have many different forms or stages. This principle can also be applied to object-oriented programming and languages like the Java language. Subclasses of a class can define their own unique behaviors and yet share some of the same functionality of the parent class 我认为有两点:
- 类和它的具体行为(方法)可以有多种形式
- 形式的选择留到运行时决定
多态的具体表现就是方法的重载和覆盖。那么虚拟机是如何在运行时决定执行哪个方法的呢? 我们知道方法的信息 和其他类信息一样都存在方法区中,如果一个方法的引用在方法区中只存在一个实例,则并不需要虚拟机做额外的工作,既可以直接将引用直接替换为真正的方法给执行引擎,也就不是“多态”的问题范围了。这样的情况通常包括:
- 静态方法
- 私有方法(有重载,但没有重写。重载虽然也是多态的表现形式,但因为具有不同的方法签名,所以对虚拟机来说,一个方法能直接对应到实例。反而对编译器来说,需要在编译时判断一个方法调用指向的具体是哪个重载方法)
- Final方法(静态方法隐含了final的含义)
而对于其他情况类说,方法可以通过类的继承和复写带来“歧义”,编译器也并不能将这个歧义消除,而需要虚拟机来处理,对于这样的情况,虚拟机(具体来说是invokeVirtual指令)按照这样的规则来处理诸如a.method(para)这样的方法调用的:
- 找到a实例的类型信息,也就是a的实际类型
- 从a的实际类型信息中查找有没有与“method(para)”方法签名一致的方法,如果有则表示绑定成功,进行下一步
- 否则从a类型的父类开始往上查找,直到找到方法签名一致的方法,并将方法信息给执行引擎进行方法执行。
类加载
虚拟机启动时通过自带的类加载器(Bootstrap ClassLoader)来加载类文件,对类文件进行验证、链接、初始化之后,将类信息(也是一种数据结构)放到本文前面说的方法区(应该取个更好的名字,不仅仅是方法信息)。 除此之外,程序员也可通过自定义的类加载器来加载指定的其他类文件。有点需要注意的是不同的类加载器加载的类可以被理解为有不同的平行世界,哪怕是同一个类文件,如果被不同的类加载器加载的话,会在方法区存在两个类文件,因此判断类型是否相同的操作也会返回false。由于任意 所以,类加载器的设计最好遵循一种协议,以确保java核心类或公共类在应用范围内不需要被重复加载,并可以允许用户定义自己的类加载器,并加载用户指定的类。这种协议可以通过下面的图来表示:
From:http://images.cnblogs.com/cnblogs_com/yayagepei/classLoader.jpg
开发者可以通过扩展SystemClassLoader,并复写findClass方法实现自己的类查找和加载逻辑,并遵循这样的规则:
- 当尝试加载任意一个类,首先尝试通过父类加载器加载,如果记载失败才开始进入自定义的加载。
- 当判断一个类是否已经被加载时,首先尝试判断自己是否已经加载过该类,如果不存在,才开始通过父类来判断。