沉淀再出发:jvm的本质
沉淀再出发:jvm的本质
一、前言
关于jvm,使用的地方实在是太多了,从字面意思上我们都能明白这也是一个虚拟机,那么其他的虚拟机都会用来运行别的操作系统的,而jvm却是实现了可以在不用的操作系统之上运行同样的字节码文件,以此来实现代码的可移植性,大家可以看一下编译原理,就知道了jvm运行代码的本质其实是根据不同的平台将字节码文件(中间代码)变成最终适合不同平台的机器码。同时jvm中也有很多的概念,肯定也是和编译系统相关的了,数据和代码如何存储,数据分为哪几种数据,需要什么格式存储,堆栈等等,以及相关数据的生存周期,垃圾回收机制,由此产生的一系列的问题,函数的存储和调用,理解到这个程度,我们就能更好地理解使用java进行开发的其他软件,比如hadoop等等。同样的,对于进程和线程的存储和执行的情况,并发以及volatile我们都有了更深的理解。
二、jvm初探
2.1、java平台
Java平台由Java虚拟机和Java应用程序接口搭建,Java语言则是进入这个平台的通道,用Java语言编写并编译的程序可以运行在这个平台上。这个平台的结构如下图所示:
2.2、JVM体系结构
1) 类装载器(ClassLoader)(用来装载.class文件)
2) 执行引擎(执行字节码,或者执行本地方法)
3) 运行时数据区(方法区、堆、java栈、PC寄存器、本地方法栈)
2.3、JVM生命周期
1 启动:启动一个Java程序时,一个JVM实例就产生了,任何一个拥有public static void main(String[] args)函数的class都可以作为JVM实例运行的起点。 2 运行:main()作为该程序初始线程的起点,任何其他线程均由该线程启动。 3 消亡:当程序中的所有非守护线程都终止时,JVM才退出;若安全管理器允许,程序也可以使用Runtime类或者System.exit()来退出。
一个运行中的Java虚拟机有着清晰的任务:执行Java程序。程序开始执行时才运行,程序结束时就停止。在同一台机器上运行三个程序,就会有三个运行中的Java虚拟机。 Java虚拟机总是开始于一个main()方法,这个方法必须是公有、返回void、只接受一个字符串数组。在程序执行时,必须给Java虚拟机指明main()方法的类名。main()方法是程序的起点,执行的线程初始化为初始线程,程序中其他的线程都由其来启动。
Java中的线程分为两种:守护线程 (daemon)和普通线程(non-daemon)。守护线程是Java虚拟机自己使用的线程,比如负责垃圾收集的线程就是一个守护线程。当然,也可以把自己的程序设置为守护线程,包含main()方法的初始线程不是守护线程。只要Java虚拟机中还有普通的线程在执行,Java虚拟机就不会停止。如果有足够的权限,可以调用exit()方法终止程序。
2.4、 JVM运行时数据区
Java堆(Heap)
被所有线程共享的一块内存区域,在虚拟机启动时创建用来存储对象实例,可以通过-Xmx和-Xms控制堆的大小;OutOfMemoryError异常:当在堆中没有内存完成实例分配,且堆也无法再扩展时。java堆是垃圾收集器管理的主要区域。java堆还可以细分为:新生代(New/Young)、旧生代/年老代(Old/Tenured)。持久代(Permanent)在方法区,不属于Heap。
1 新生代:新建的对象都由新生代分配内存。常常又被划分为Eden区和Survivor区。Eden空间不足时会把存活的对象转移到Survivor。新生代的大小可由-Xmn控制,也可用-XX:SurvivorRatio控制Eden和Survivor的比例。 2 旧生代:存放经过多次垃圾回收仍然存活的对象。 3 持久代:存放静态文件,如Java类、方法等;持久代在方法区,对垃圾回收没有显著影响。
方法区
线程间共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器(JIT)编译后的代码等数据;OutOfMemoryError异常:当方法区无法满足内存的分配需求时。JVM用持久代(Permanet Generation)来存放方法区。
运行时常量池:方法区的一部分,用于存放编译期生成的各种字面量与符号引用,如String类型常量就存放在常量池;OutOfMemoryError异常:当常量池无法再申请到内存时。
java虚拟机栈(VM Stack)
线程私有,生命周期与线程相同;存储方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。 java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧,每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
1 StackOverflowError异常:当线程请求的栈深度大于虚拟机所允许的深度 2 OutOfMemoryError异常:如果栈的扩展时无法申请到足够的内存
JVM栈是线程私有的,每个线程创建的同时都会创建JVM栈,JVM栈中存放的为当前线程中局部基本类型的变量、部分的返回结果以及Stack Frame。其他引用类型的对象在JVM栈上仅存放变量名和指向堆上对象实例的首地址。
本地方法栈(Native Method Stack)
与虚拟机栈相似,主要为虚拟机使用到的Native方法服务,在HotSpot虚拟机中直接把本地方法栈与虚拟机栈二合一。用于支持native方法的执行,存储了每个native方法调用的状态。对于本地方法接口,实现JVM并不要求一定要有它的支持,甚至可以完全没有。Sun公司实现Java本地接口(JNI)是出于可移植性的考虑,当然我们也可以设计出其它的本地接口来代替Sun公司的JNI。但是这些设计与实现是比较复杂的事情,需要确保垃圾回收器不会将那些正在被本地方法调用的对象释放掉。
程序计数器(Program Counter Register)
当前线程所执行的字节码的行号指示器,当前线程私有,不会出现OutOfMemoryError情况。程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
如果线程正在执行的是Java 方法,则这个计数器记录的是正在执行的虚拟机字节码指令地址;
如果正在执行的是Native 方法,则这个技术器值为空(Undefined);程序计数器记录的字节码指令地址,但是native 本地(如:System.currentTimeMillis()/ public static native long currentTimeMillis();)方法是大多是通过C实现并未编译成需要执行的字节码指令所以在计数器中当然是空(undefined)。
让我们通过一个例子来看:
1 public class ZyrCal { 2 public static void main(String [] args){ 3 System.out.println(calc()); 4 } 5 public static int calc(){ 6 int a = 100; 7 int b = 200; 8 int c = 300; 9 return ( a + b ) * c; 10 } 11 }
native 方法的多线程实现方式:
native 方法是通过调用系统指令来实现的,那系统是如何实现多线程的,native 就是如何实现的。Java线程总是需要以某种形式映射到OS线程上,映射模型可以是1:1(原生线程模型)、n:1(绿色线程 / 用户态线程模型)、m:n(混合模型)。以HotSpot VM的实现为例,它目前在大多数平台上都使用1:1模型,也就是每个Java线程都直接映射到一个OS线程上执行。此时,native方法就由原生平台直接执行,并不需要理会抽象的JVM层面上的“pc寄存器”概念,原生的CPU上真正的PC寄存器是怎样就是怎样,就像一个用C或C++写的多线程程序。
直接内存(Direct Memory)
直接内存并不是虚拟机运行的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁使用;NIO可以使用Native函数库直接分配堆外内存,堆中的DirectByteBuffer对象作为这块内存的引用进行操作。大小不受Java堆大小的限制,受本机(服务器)内存限制。OutOfMemoryError异常:系统内存不足时。
Java对象实例存放在堆中;
常量存放在方法区的常量池;
虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据放在方法区;
以上区域是所有线程共享的。
栈是线程私有的,存放该方法的局部变量表(基本类型、对象引用)、操作数栈、动态链接、方法出口等信息。
一个Java程序对应一个JVM,一个方法(线程)对应一个Java栈。
2.4、Java代码的编译和执行过程
Java代码的编译和执行包括了三个重要机制:
(1)Java源码编译机制(.java源代码文件 -> .class字节码文件) (2)类加载机制(ClassLoader) (3)类执行机制(JVM执行引擎)
2.4.1、Java源码编译机制
Java源代码是不能被机器识别的,需要先经过编译器编译成JVM可以执行的.class字节码文件,再由解释器解释运行。即Java源文件(.java) -- Java编译器 --> Java字节码文件 (.class) --> Java解释器 --> 执行。流程图如下:
字节码文件(.class)是平台无关的。Java中字符只以Unicode一种形式存在。字符转换发生在JVM和OS交界处(Reader/Writer)。最后生成的class文件由以下部分组成:
1 结构信息:包括class文件格式版本号及各部分的数量与大小的信息 2 元数据:对应于Java源码中声明与常量的信息。包含类/继承的超类/实现的接口的声明信息、域与方法声明信息和常量池 3 方法信息:对应Java源码中语句和表达式对应的信息。包含字节码、异常处理器表、求值栈与局部变量区大小、求值栈的类型记录、调试符号信息
2.4.2、类加载机制(ClassLoader)
Java程序是由多个独立的类文件组成。这些类文件并非一次性全部装入内存,而是依据程序逐步载入。JVM的类加载是通过ClassLoader及其子类来完成的,类的层次关系和加载顺序可以由下图来描述:
1 1、Bootstrap ClassLoader 2 JVM的根ClassLoader,由C++实现 3 加载Java的核心API:$JAVA_HOME中jre/lib/rt.jar中所有class文件的加载,这个jar中包含了java规范定义的所有接口以及实现。 4 JVM启动时即初始化此ClassLoader 5 2、Extension ClassLoader 6 加载Java扩展API(lib/ext中的类) 7 3、App ClassLoader 8 加载Classpath目录下定义的class 9 4、Custom ClassLoader 10 属于应用程序根据自身需要自定义的ClassLoader,如tomcat、jboss都会根据J2EE规范自行实现ClassLoader。
1 双亲委派机制:JVM在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归。如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。 2 作用:1)避免重复加载;2)更安全。如果不是双亲委派,那么用户在自己的classpath编写了一个java.lang.Object的类,那就无法保证Object的唯一性。所以使用双亲委派,即使自己编写了,但是永远都不会被加载运行。 3 4 破坏双亲委派机制:双亲委派机制并不是一种强制性的约束模型,而是Java设计者推荐给开发者的类加载器实现方式。线程上下文类加载器,这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那么这个类加载器就是应用程序类加载器。像JDBC就是采用了这种方式。这种行为就是逆向使用了加载器,违背了双亲委派模型的一般性原则。
2.4.3、类执行机制
Java字节码的执行是由JVM执行引擎来完成,流程图如下所示:
JVM是基于栈的体系结构来执行class字节码的,线程创建后,都会产生程序计数器(PC)和栈(Stack),程序计数器存放下一条要执行的指令在方法内的偏移量,栈中存放一个个栈帧,每个栈帧对应着每个方法的每次调用,而栈帧又是有局部变量区和操作数栈两部分组成,局部变量区用于存放方法中的局部变量和参数,操作数栈中用于存放方法执行过程中产生的中间结果。
主要的执行技术:解释,即时编译,自适应优化、芯片级直接执行
1 解释属于第一代JVM, 2 即时编译JIT属于第二代JVM, 3 自适应优化(目前Sun的HotspotJVM采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式 4 开始对所有的代码都采取解释执行的方式,并监视代码执行情况。
对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行优化。
若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。
JIT工作原理
JIT是just in time,即时编译技术。使用该技术,可以加速java程序的运行速度。当JIT编译启用时(默认是启用的),JVM读入.class文件解释后,将其发给JIT编译器。JIT编译器将字节码编译成本机机器代码。通常javac将程序源码编译,转换成java字节码,JVM通过解释字节码将其翻译成相应的机器指令,逐条读入,逐条解释翻译。非常显然,经过解释运行,其运行速度必定会比可运行的二进制字节码程序慢。为了提高运行速度,引入了JIT技术。在执行时JIT会把翻译过的机器码保存起来,已备下次使用,因此从理论上来说,採用该JIT技术能够,能够接近曾经纯编译技术。JIT并不总是奏效,不能期望JIT一定可以加速你代码运行的速度,更糟糕的是她有可能减少代码的运行速度。这取决于你的代码结构,当然非常多情况下我们还是可以如愿以偿的。从上面我们知道了之所以要关闭JIT,java.lang.Compiler.disable(); 是由于加快运行的速度。由于JIT对每条字节码都进行编译,造成了编译过程负担过重。为了避免这样的情况当前的JIT仅仅对常常运行的字节码进行编译如循环等 。
2.5、JVM垃圾回收(GC)
GC的基本原理:将内存中不再被引用的对象(垃圾)进行回收,GC中用于回收的方法称为收集器。由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停。
1 对新生代的对象的收集称为minor GC; 2 对旧生代的对象的收集称为Full GC; 3 程序中主动调用System.gc()的GC为Full GC。
Java垃圾回收是单独的后台线程gc执行的,自动运行无需显示调用。即使主动调用了java.lang.System.gc(),该方法也只会提醒系统进行垃圾回收,但系统不一定会回应,可能会不予理睬。
判断一块内存空间是否符合回收标准:
(1)对象赋予了空值,且之后再未调用(obj = null;) (2)对象赋予了新值,即重新分配了内存空间(obj = new Obj();)
内存泄漏:程序中保留着对永远不再使用的对象的引用。因此这些对象不回被GC回收,却一直占用内存空间却毫无用处。即:1)对象是可达的;2)对象是无用的。满足这两个条件即可判定为内存泄漏。内存泄露的原因:1)全局集合;2)缓存;3)ClassLoader。
应确保不需要的对象不可达,通常采用将对象字段设置为null的方式,或从容器collection中移除对象。局部变量不再使用时无需显式设置为null,因为对局部变量的引用会随着方法的退出而自动清除。
2.6、内存调优
调优目的:减少GC的频率尤其是Full GC的次数,过多的GC会占用很多系统资源影响吞吐量。特别要关注Full GC,因为它会对整个堆进行整理。
主要手段:JVM调优通过配置JVM的参数来提高垃圾回收的速度,合理分配堆内存各部分的比例。
导致Full GC的几种情况和调优策略:
1 旧生代空间不足 2 调优时尽量让对象在新生代GC时被回收、让对象在新生代多存活一段时间和不要创建过大的对象及数组避免直接在旧生代创建对象 3 持久代(Pemanet Generation)空间不足 4 增大Perm Gen空间,避免太多静态对象 5 统计得到的GC后晋升到旧生代的平均大小大于旧生代剩余空间 6 控制好新生代和旧生代的比例 7 System.gc()被显示调用 8 垃圾回收不要手动触发,尽量依靠JVM自身的机制
堆内存比例不良设置导致的后果:
12)新生代设置过大 一是新生代设置过大会导致旧生代过小(堆总量一定),从而诱发Full GC; 二是新生代GC耗时大幅度增加; 一般说来新生代占整个堆1/3比较合适; 34)Survivor设置过大 -XX:MaxTenuringThreshold=n来控制新生代存活时间,尽量让对象在新生代被回收。
JVM提供两种较为简单的GC策略的设置方式:
1 1)吞吐量优先 2 JVM以吞吐量为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,来达到吞吐量指标。这个值可由-XX:GCTimeRatio=n来设置 3 2)暂停时间优先 4 JVM以暂停时间为指标,自行选择相应的GC策略及控制新生代与旧生代的大小比例,尽量保证每次GC造成的应用停止时间都在指定的数值范围内完成。这个值可由-XX:MaxGCPauseRatio=n来设置。
JVM常见配置:
1 堆设置 2 -Xms:初始堆大小 3 -Xmx:最大堆大小 4 -XX:NewSize=n:设置年轻代大小 5 -XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4 6 -XX:SurvivorRatio=n:年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5 7 -XX:MaxPermSize=n:设置持久代大小 8 收集器设置 9 -XX:+UseSerialGC:设置串行收集器 10 -XX:+UseParallelGC:设置并行收集器 11 -XX:+UseParalledlOldGC:设置并行年老代收集器 12 -XX:+UseConcMarkSweepGC:设置并发收集器 13 垃圾回收统计信息 14 -XX:+PrintGC 15 -XX:+PrintGCDetails 16 -XX:+PrintGCTimeStamps 17 -Xloggc:filename 18 并行收集器设置 19 -XX:ParallelGCThreads=n:设置并行收集器收集时使用的CPU数。并行收集线程数。 20 -XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间 21 -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比。公式为1/(1+n) 22 并发收集器设置 23 -XX:+CMSIncrementalMode:设置为增量模式。适用于单CPU情况。 24 -XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的CPU数。并行收集线程数。
三、多线程下的jvm
java里的变量可以分3类:
1 类变量(类里面static修饰的变量) 2 实例变量(类里面的普通变量) 3 局部变量(方法里声明的变量)
根据各个区域的定义,我们可以知道:
1 类变量 保存在“方法区” 2 实例变量 保存在“堆” 3 局部变量 保存在 “虚拟机栈”
“方法区”和“堆”都属于线程共享数据区,“虚拟机栈”属于线程私有数据区。
因此,局部变量是不能多个线程共享的,而类变量和实例变量是可以多个线程共享的。事实上,在java中,多线程间进行通信的唯一途径就是通过类变量和实例变量。也就是说,如果一段多线程程序中如果没有类变量和实例变量,那么这段多线程程序就一定是线程安全的。
以Web开发的Servlet为例,一般我们开发的时候,自己的类继承HttpServlet之后,重写doPost()、doGet()处理请求,不管我们在这两个方法里写什么代码,只要没有操作类变量或实例变量,最后写出来的代码就是线程安全的。如果在Servlet类里面加了实例变量,就很可能出现线程安全性问题,解决方法就是把实例变量改为ThreadLocal变量,而ThreadLocal实现的含义就是让实例变量变成了“线程私有”的,即给每一个线程分配一个自己的值。
现在我们知道:其实多线程根本的问题只有一个:线程间变量的共享,这里的变量,指的就是类变量和实例变量,后续的一切,都是为了解决类变量和实例变量共享的安全问题。
四、总结
通过对jvm的学习,我们可以深刻地理解到程序的执行原理,以及背后的内存和CPU的处理情况,对我们理解多线程,高并发,内存管理,内存优化,代码优化等有着重要的作用。