您的位置:首页 > 编程语言 > Java开发

深入理解Java虚拟机【总结】

2018-02-24 15:39 621 查看
1. 虚拟机内存区域

(1)程序计数器。这是一块比较小的内存区域,可以看作是当前线程所执行字节码的行号指示器。Java虚拟机多线程是通过线程轮流切换并分配处理器运行时间来实现的,为了使线程切换后能恢复到正确的位置,每条线程都需要一个独立的计数器,各条线程的程序计数器独立存储,互不影响,线程私有。如果当前线程执行的是一个Java方法,那么程序计数器里存放的就是线程所执行字节码的地址,如果当前线程执行的是一个Native方法,那么程序计数器中值为空。程序计数器是唯一一块没有OOM的区域。

(2)虚拟机栈。与程序计数器一样也是线程私有的,生命周期与线程相同。Java虚拟机栈是描述方法执行的内存模型。方法执行时会创建一个栈帧,里面存放局部变量表、操作数栈、动态链接、方法出口等信息。方法的调用到执行结束对应着一个栈帧从入栈到出栈的过程。我们平时经常把Java内存区域简单的划分成堆和栈,这里的栈指的就是虚拟机栈,更确切的说是虚拟机栈中的局部变量表。局部变量表中存放的是编译器可知的各种基本数据类型(Java八大基本数据类型)以及对象引用等。其中64位long和double类型的数据占用2位局部变量表空间,其他类型的数据占用1位局部变量表空间。局部变量表所需的内存空间在编译期就已分配完毕,方法运行期间不会改变局部变量表大小。虚拟机栈有2中异常情况,一种是线程请求的栈的深度大于虚拟机所允许的栈深度,就会抛出StackOverFlowError。另一种是虚拟机栈无法申请到足够的内存时,会抛出OOM。

(3)本地方法栈。与虚拟机栈类似,不同的是,虚拟机栈是为执行Java方法服务的,而本地方法栈是为执行native方法服务的。Hotspot虚拟机就把本地方法栈和虚拟机栈合二为一。它也会抛出StackOverflowError和OOM异常。

(4)Java堆。是jvm所管理的最大的一块内存空间,所有线程共享的。jvm启动时创建,堆存在的目的就是为了存放所有对象实例和数组。它也是垃圾回收的主要区域,所以也经常被称为GC堆。从内存回收的角度看,因为目前大多数垃圾回收器都采用分代回收算法,所以堆分为新生代和老年代,新生代进一步可划分为Eden区,from survior区和to survivor区。从内存分配的角度看,Java堆可以划分成多个线程私有的缓冲区(TLAB)。无论从内存回收还是内存空间划分,堆中都是存储对象实例的。Java堆可以物理上不连续,只要逻辑上连续就可以,像硬盘空间一样。如今大多数流行的Java虚拟机的堆都是可以动态扩展的(通过-Xmx和-Xms)。当堆无法申请到足够的内存,并且不可以再扩展时就会抛出OOM异常。

(5)方法区。与Java堆一样也是线程共享的。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。我们习惯称方法区为永久代,但其中的数据并非永久,在JDK1.7以后,将永久代中的字符串常量池移出。该区域也会OOM。

(6)运行时常量池。是方法区的一部分。保存Class文件中描述的符号引用和直接引用。

2. 如何判断对象是否存活

(1)引用计数算法:给对象中添加一个引用计数器,当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1。当任意时刻,只要计数器值为0,就表示该对象已死亡。这种方法实现简单,效率也高,但是当今主流的JVM中没有采用这种方法去管理内存,主要原因是它很难解决对象之间相互引用的问题。

(2)可达性分析算法:由一系列称为GC roots的对象作为起始点,从这些节点向下搜索,搜索所走过的路径称为引用链。当一个对象到GC roots没有任何引用链相连时,则证明此对象是不可用的。但也并不代表这个对象非死不可,宣告一个对象死亡,要经过2次标记过程:如果对象在可达性分析中,没有与GC roots相连的引用链,那么它们将会被第一次标记并进行1次筛选,筛选的条件是该对象是否有必要执行finalize方法,如果该对象没有覆盖finalize方法或已经执行过finalize方法,这两种情况都被视为没有必要执行finalize方法。如果这个对象被判定为有必要执行finalize方法,它就被放置在一个叫F-Queue的队列上,然后虚拟机会创建一个低优先级的Finalizer线程去执行finalize方法,但并不承诺会等待它执行完毕,原因是如果finalize方法执行缓慢,甚至出现了死循环,这会影响F-Queue中其他对象,甚至会使整个垃圾回收器崩溃。finalize方法是对象逃脱死亡的最后一次机会,如果对象要在finalize方法中成功拯救自己,只需要让自己(this)与引用链中任意一个对象建立关联,那么在第二次标记时它将会被移除出队列,如果没有移除,那就是注定被回收了。

在Java中,可以作为GC roots的对象包括下面几种:

虚拟机栈(栈帧中的本地变量表)中引用的对象;

方法区中类静态属性引用的对象;

方法区中常量引用的对象;

本地方法栈中JNI(一般说的Native方法)引用的对象。

3. 引用的分类

(1)强引用:在代码中普遍存在的一种引用。如Object obj = new Object(),只要强引用存在,垃圾回收器就永远不会回收这个对象
cae4


(2)软引用:用来描述那些有用但非必需的对象。在内存足够的时候不会回收它们,当内存要发生溢出之前,会回收它们。如果回收后还没有足够的内存,就OOM。

(3)弱引用:用来描述那些非必需的对象,比软引用还要弱一些。仅持有弱引用的对象只能存活到下次垃圾回收之前,当垃圾回收器工作时,无论当前内存是否够用,仅持有弱引用的对象都会被回收。

(4)虚引用:这种引用形同虚设。它的存在完全不会影响对象的生存时间。为一个对象设置虚引用的唯一目的就是能在这个对象被垃圾回收器回收时能收到一个系统通知。

4. 垃圾回收算法

(1)垃圾清除算法:

标记所有需要回收的对象,回收所有被标记的对象。

缺点:

效率问题:标记和清除过程效率都不高;

空间问题:回收后会产生很多内存碎片,以后在分配大内存对象时,无法找到足够的连续空间而不得不提前触发下一次垃圾回收。

(2)复制算法:

将内存划分为大小相等的2块,每次只使用其中的1块,当这一个块用完了,就把还存活的对象复制到另外一块,然后把刚才使用过的那半块清除掉。这样就不会有内存碎片,但是只能使用一半内存,利用率低。多用于回收新生代。

(3)标记整理算法:

标记所有需要回收的对象,把被标记的对象向一端移动,然后清除边界以外的对象。多用于回收老年代。

(4)分代回收算法:

当前流行的JVM都采用这种回收算法。根据对象的存活周期的不同将内存分为新生代和老年代,新生代存活的对象少,就采用复制算法。老年代存活的对象多,就采用标记清除或标记整理算法。

5. 垃圾收集器

(1)Serial收集器:

单线程,Client模式下的虚拟机默认新生代收集器。复制算法。

(2)Serial Old收集器:

Serial的老年代版本,标记整理算法。

(3)ParNew收集器:

Serial的多线程版本,运行在Server模式下的虚拟机首选的新生代收集器。除Serial收集器外,只有它可以与CMS收集器配合工作。

(4)Parallel Scavenge收集器:

并行的多线程新生代收集器,复制算法。与ParNew的区别是:CMS等收集器的关注点是用户线程在垃圾回收时的停顿时间,而Parallel Scavenge关注的是吞吐量,吞吐量=用户运行代码时间/用户运行代码时间+垃圾回收时间。除此之外,Parallel Scavenge还有个自适应调节策略,是一个参数,-XX:+UseAdaptiveSizePolicy,打开这个参数后,内存调优任务就由虚拟机管理,我们只需要把基本的内存数据(如-Xmx设置最大堆)设置好,然后使用MaxGCPauseMillis参数(更关注最大停顿时间)或GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标就可以了。

(5)Parallel Old收集器:

是Parallel Scavenge的老年代版本,使用多线程的标记整理算法。在没有Parallel Old时,Parallel Scavenge只能和Serial Old合作,它的多线程发挥不出应有的高效,后来搭配Parallel Old就可以了。

(6)CMS收集器:

Concurrent Mark Sweep。标记清除算法。运行分4个步骤:初始标记、并发标记、重新标记、并发清除。

其中初始标记和重新标记依然需要Stop the word。

缺点:

①CMS收集器对CPU资源非常敏感,因为并发需占用一部分线程,所以会影响用户线程。

②无法处理浮动垃圾,可能出现Concurrent Mode Failure。浮动垃圾是在并发清除时用户线程产生的,因为出现在标记过程后,所以只能下次回收。这部分垃圾也会占用内存,如果预留内存不够,就会出现Concurrent Mode Failure异常,然后临时启用Serial Old收集器,这样停顿时间就会更长。

③因为是标记清除算法,所以肯定会有内存碎片产生。

(7)G1收集器:

特点:并行与并发,分代收集(独立完成回收新生代和老年代),空间整合(标记整理算法,两个region之间采用复制算法),可预测的时间停顿。

大致步骤:

初始标记,并发标记,最终标记,筛选回收。前三步与CMS相同,筛选回收是G1收集器把堆分成若干大小相等的region,并跟踪每个region里面的垃圾回收价值(回收后所获得空间大小,回收所需的时间),在后台维护一个优先列表,每次根据垃圾回收价值最大的进行优先回收。

6. 垃圾收集器参数

UseSerialGC:Client模式下的默认值,使用Serial+Serial Old

UseParNewGC:使用ParNew+Serial Old

UseConcMarkSweepGC:使用CMS+ParNew+Serial Old

UseParallelGC:Server模式下的默认值,使用Parallel Scanvenge + Parallel Old

UseParallelOldGC:使用Parallel Scanvenge + Parallel Old

SurvivorRatio:新生代中Eden区与Survivor区的比例,默认为8:1

PretenureSizeThreshold:直接晋升到老年代的大小,设置这个参数后,当大于这个值,直接在老年代分配

7. JDK命令行工具

(1)jps:

可以列出正在运行的虚拟机进程,并显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一ID(LVMID)。

(2)jstat:

可用于监视虚拟机各种运行状态。

(3)jinfo:

实时地查看和调整虚拟机参数。

(4)jmap:

用于生成堆转储快照。

(5)jhat:

与jmap搭配使用,分析堆转储快照。

(6)jstack

用于生成虚拟机当前时刻的线程快照。

线上服务内存OOM问题解决:

最常见的原因:

可能内存分配却是过小,而正常业务使用了大量内存;

某一个对象被频繁申请,却没有被释放,导致内存不断泄漏,最终内存耗尽;

某一个对象被频繁申请,还会使系统资源耗尽,比如不断创建线程,不断发起网络连接等。

假设CPU占用高的那个进程的PID为10765

(1)确认是不是内存本身就分配过小:

jmap -heap 10765

可以查看新生代、老年代内存分配情况。

(2)找到最耗内存的对象:

jmap -histo:live 10765 | more

会以表格的形式显示存活对象的信息,并按照所占内存大小排序。

如果发现某些对象占用内存很大(比如几个G),就很可能是对象创建太多且一直没释放。

(3)确认是否是资源耗尽:

工具netstat

假如:某台服务器的sshd进程PID是9339,

ll /proc/9339/fd | wc -l 进程打开的句柄数

ll /proc/9339/task | wc -l 进程的线程数

8. 类加载的过程

类从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括:

加载、验证、准备、解析、初始化、使用、卸载这7个阶段。

其中验证、准备、解析统称为连接。

加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的,而解析是不确定的,它也可以在初始化之后进行。

以下5种情况必须立即对类进行“初始化”:

① 遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4种指令最常见的Java代码场景是:使用new关键字实例化对象时、读取或设置一个类的静态字段时,以及调用一个类的静态方法时。

② 使用java.lang.reflect包的方法对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化。

③ 当初始化一个类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

④ 当虚拟机启动时,用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会初始化这个主类。

⑤ 当使用JDK1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic REF_putStatic REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则先触发其初始化。

(1)加载:是类加载过程的一个阶段。通过一个类的全限定名来获取定义此类的二进制字节流;将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

(2)验证:是连接阶段的第一步。为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

(3)准备:是正式为类变量分配内存并设置类变量初始值的阶段。这些变量所使用的内存都将在方法区中进行分配。

(4)解析:是虚拟机将常量池内的符号引用替换为直接引用的过程。

(5)初始化:是类加载过程的最后一步。是根据程序员通过程序制定的计划去初始化类变量和其他资源。

9. 类加载器

通过一个类的全限定名来获取描述此类的二进制字节流,类加载器就是用来实现这个动作的。

比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类就必定不相等。

启动类加载器【Bootstrap ClassLoader】,使用C++实现。负责把/lib下的类库加载到虚拟机内存中。不能被Java程序直接引用。

扩展类加载器【Extension ClassLoader】负责加载/lib/ext目录下的所有类库,开发者可以直接使用扩展类加载器。

应用程序类加载器【Application ClassLoader】,这个类加载器是ClassLoader中getSystemClassLoader()方法的返回值,负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

好处:例如java.lang.Object类,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器加载,因此Object类在程序的各种类加载器环境中都是同一个类。

10. Java内存模型(JMM)

屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,所以定义了一个Java内存模型。

它的主要目标就是:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,里面保存了被该线程使用到的变量的主内存副本拷贝。线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存完成。

主内存与工作内存之间具体的交互协议,有以下8种操作:

(1)lock:作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

(2)unlock:作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

(3)read:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

(4)load:作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

(5)use:作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

(6)assign:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

(7)store:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。

(8)write:作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

volatile的特性:内存可见性、禁止指令重排序。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: