Java虚拟机(JVM) - 学习总结(全)


深入理解java虚拟机---学习总结:


1.Java内存区域

1.1 java运行时数据区

 

 Java 虚拟机所管理的内存如下图所示,基于JDK1.6。

 

基于jdk1.8画的JVM的内存模型

 

(1) 程序计数器:当前线程所执行的字节码的行号指示器,内存空间小,线程私有。

  内存溢出情况:唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。



(2) 虚拟机栈:描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。线程私有,生命周期和线程一致。

局部变量表:存放了编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型)和 returnAddress 类型(指向了一条字节码指令的地址)。

内存溢出情况:StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度。

                       OutOfMemoryError:如果虚拟机栈可以动态扩展,而扩展时无法申请到足够的内存。


(3) 本地方法栈:本地方法栈是为虚拟机使用到的Native方法服务。在Hotspot虚拟机中本地方法栈与虚拟机栈中合二为一。

内存溢出情况: StackOverflowError 和 OutOfMemoryError 异常。


(4) :存放对象实例和数组,几乎所有的对象实例都在这里分配内存。

内存溢出情况:OutOfMemoryError


(5) 方法区:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。(元空间)

运行时常量池:是方法区的一部分,class文件除了有类的字段、接口、方法等描述信息之外,还有
常量池用于存放编译期间生成的各种字面量和符号引用。

 

简单描述:

:存放对象的实例以及对象的属性和方法

:储存基本数据类型的值、执行的方法、方法中声明的变量、数组、对象的引用(reference类型)

方法区:存储已被虚拟机加载的类元数据信息(元空间)

运行时常量池:常量(final)、字符串

 

 

 

关于常量池,下面这两篇文章写得很好:

https://blog.csdn.net/wangbiao007/article/details/78545189

https://blog.csdn.net/vegetable_bird_001/article/details/51278339

 

在 JDK 1.8 中,HotSpot 已经没有 “PermGen space”这个空间了,取而代之是一个叫做 Metaspace(元空间) 的东西。

 

 Java7中已经将字符串常量池从永久代移除,在Java 堆(Heap)中开辟了一块区域存放字符串常量池。而在Java8中,已经彻底没有了永久代,将方法区直接放在一个与堆不相连的本地内存区域,这个区域被叫做元空间。 

 

 

Metaspace(元空间)

  其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。

  元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小。

 

 

2.对象的创建过程

2.1 Java对象的创建过程

对象创建过程:类加载检查、分配内存、初始化零值、设置对象头、执行init()方法。

(1) 类加载检查:当虚拟机遇到一条new指令时,首先去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,
并检查这个符号引用所代表的类是否已经被加载、解析和初始化过。如果没有,则执行相应的类加载过程。


(2) 分配内存:把一块确定大小的内存从java堆中分配出来

分配方式:指针碰撞空闲列表

用哪种方式取决于堆的规整性,而堆的规整性又取决于采用的GC收集器算法是"标记-清除"还是"标记-整理",
值得注意的是,复制算法内存也是规整的。


指针碰撞: 使用场合:堆内存规整的情况下,既没有内存碎片

原理: 将用过的内存全部移动到一端,没用过的内存移动到另一端,中间有个分界值指针,
只需要沿着没用过的内存移动指针对象即可。

GC收集器: Serial、ParNew


空闲列表: 使用场合:堆内存不规整

原理:虚拟机会维护一个列表,该列表会记录哪些内存是可用的,在分配的时候会找一块足够大
的内存来分配给对象,最后更新列表记录。

GC收集器:CMS收集器(concurrent mark sweep 并发标记清除算法)

内存分配并发问题:在创建对象的时候回遇到一个很重要的问题,就是线程安全问题

虚拟机采用两种方法保证线程安全:

CAS+失败重试:CAS(Compare And Swap 比较并替换)是乐观锁的一种实现方式。所谓的乐观锁就是,每次不加锁而假设没有冲突去完成某项操作,
如果因为冲突失败就重试,直到成功为止。虚拟机采用CAS+失败重试的方式更新操作的原子性。

TLAB:为每一个线程预先在eden区分配一块内存,jvm在给线程中的对象分配内存时,首先在tlab分配,当对象大于
TLAB的剩余空间或TLAB的内存满了时,再采用上述的CAS进行内存分配。


(3) 初始化零值:将内存空间都初始化为零值,让对象的实例字段在代码中不用赋初始值就可以直接使用。

(4) 设置对象头:对象头中保存着对象的哈希码、GC分代年龄、锁的状态标志等。

(5) 执行init()方法:执行init()方法,把对象按照程序员的意愿进行初始化。


2.2 对象的访问定位(使用句柄和直接指针的方式)

创建对象就是为了使用对象,java程序通过栈上的reference数据来操作堆上的具体对象,主流的对象访问
方式有两种:1.使用句柄 2.直接指针

 


1.使用句柄 :如果使用句柄,那么java会在堆中开辟一块内存来当作句柄池,reference中储存着句柄的地址,
而句柄包含了对象的实例数据和对象的类型数据各个的地址信息。

 

 

2.直接指针:直接储存对象的地址。

区别:使用句柄最大的好处是reference中储存的是稳定的句柄地址,当对象被移动时只会修改句柄中的实例数据指针,
而不用修改reference。使用直接指针的好处是速度快。

 

3.垃圾回收机制和内存分配策略

3.1 哪些内存需要回收?

由于程序计数器、虚拟机栈、本地方法栈的生命周期都跟随线程的生命周期,当线程销毁了,
内存也就回收了,所以这几个区域不用过多地考虑内存回收。由于堆和方法区的内存都是动态
分配的,而且是线程共享的,所以内存回收主要关注这部分区域。

3.2 如何判断对象是否存活?

(1)引用计数法:

给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,如果引用失效,计数器值减1,
所以当该计数器的值为0时,就表示该对象可以被回收了。但是存在两个对象之间相互循环引用的问题。

(2)可达性分析算法:

通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索的路径称为引用链,
当一个对象到“GC Roots”没有任何引用链相连的话,也就是GC Roots到这个对象不可达时,证明此对象
已经不可用,可以被回收了。

二次标记:

在可达性分析算法中被判断是对象不可达时不一定会被垃圾回收机制回收,因为要真正宣告一个对象
的死亡,必须经历两次标记的过程。如果发现对象不可达时,将会进行第一次标记,此时如果该对象
调用了finalize()方法,那么这个对象会被放置在一个叫F-Queue的队列之中,如果在此队列中该对象
没有成功拯救自己(拯救自己的方法是该对象有没有被重新引用),那么GC就会对F-Queue队列中的对象
进行小规模的第二次标记,一旦被第二次标记的对象,将会被移除队列并等待被GC回收,所以finalize()
方法是对象逃脱死亡命运的最后一次机会。

可作为 GC Roots 的对象:

(1)虚拟机栈(栈帧中的本地变量表)中引用的对象
(2)方法区中类静态属性引用的对象
(3)方法区中常量引用的对象
(4)本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象

 

3.3 垃圾收集算法

(1)标记—清除算法:

标记阶段:先通过根节点,标记所有从根节点开始的对象,未被标记的为垃圾对象。
清除阶段:清除垃圾对象。

缺点:效率不高、会产生空间碎片。


(2)复制算法:将内存分为大小相同的两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面。

特点:并不会产生内存碎片,但是代价是把内存缩小了一半,效率比较低。


(3)标记-整理算法:标记-清除算法一样,区别是清除的时候会把所有存活的对象移到一端。

(4)分代回收算法:根据存活对象划分几块内存区,一般是分为新生代和老年代。然后根据各个年代的特点制定相应的回收算法。

新生代:每次垃圾回收都有大量对象死去,只有少量存活,选用复制算法比较合理。

老年代:老年代中对象存活率较高、没有额外的空间分配对它进行担保。所以必须使用标记—清除算法或者标记—整理算法。



3.4 垃圾回收器:

 

 

(1)Serial 收集器:单线程收集器,在进行垃圾回收时必须暂停其它所有的工作线程直到收集结束。

新生代:使用复制算法。 老年代:标记整理算法。

(2)ParNew 收集器:可以认为是 Serial 收集器的多线程版本。

新生代:使用复制算法。 老年代:标记整理算法。

(3)Parallel Scavenge 收集器:这是一个新生代收集器,也是使用复制算法实现,同时也是并行的多线程收集器。

(4)Serial Old 收集器:收集器的老年代版本,单线程,使用标记整理算法。

(5)Parallel Old 收集器:Parallel Old 是 Parallel Scavenge 收集器的老年代版本。多线程,使用标记整理算法。

(6)CMS 收集器:CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。基于标记-清除算法实现。

回收过程主要分为四个步骤:

(1)初始标记:标记一下GC Roots能直接关联到的对象,速度很快;
(2)并发标记:进行GC Roots Tracing的过程,也就是标记不可达的对象,相对耗时;
(3)重新标记:修正并发标记期间因用户程序继续运作导致的标记变动,速度比较快;
(4)并发清除:对标记的对象进行统一回收处理,比较耗时;

缺点:对 CPU 资源敏感、无法收集浮动垃圾、有大量的空间碎片

(7)G1 收集器:面向服务端的垃圾回收器,基于“标记-整理”算法实现。

回收过程主要分为四个步骤:

(1)初始标记:标记一下GC Roots能直接关联到的对象,速度很快;
(2)并发标记:进行GC Roots Tracing(搜索标记)的过程,也就是标记不可达的对象,相对耗时 ;
(3)最终标记:修正并发标记期间因用户程序继续运作导致的标记变动,速度比较快;
(4)筛选回收:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划;

G1收集器的特点
(1)并发与并行:机型垃圾收集时可以与用户线程并发运行;
(2)分代收集:能根据对象的存活时间采取不同的收集算法进行垃圾回收;
(3)不会产生内存碎片:基于标记——整理算法和复制算法保证不会产生内存空间碎片;
(4)可预测的停顿:G1除了追求低停顿时间外,还能建立可预测的停顿时间模型,便于用户的实时监控;


3.5 内存分配策略:

 

内存分配策略:

(1)对象优先在 Eden 分配
(2)大对象直接进入老年代
(3)长期存活的对象将进入老年代
(4)动态对象年龄判定
(5)空间分配担保

堆分为:新生代、老年代,
新生代又分为Eden、From、To区

新生代:老年代=1/3:2/3 ,Eden:From:To=8:1:1

 




  对象都会首先在 Eden 区域分配,当 eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC(年轻代GC)。
在GC前,对象存在于eden区和From区,在一次新生代垃圾回收后,如果对象还存活,则会进入To区,并且对象的年龄
还会加 1,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。经过这次GC后,Eden区和"From"区已经被清空。
这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次GC前的“From”,新的"From"就是上次GC前的"To"。
不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,"To"区被填满之后,
会将所有对象移动到年老代中。

3.6 引用类型

强引用:指向使用new关键字创建的对象的引用都是强引用,只要该对象的强引用还在,该对象永远都不会被GC回收;
软引用:当内存不足时,就会被回收;
弱引用:只要发生GC,就会被回收;
虚引用:随时都会被回收;


4.类加载机制

4.1 类加载的过程

七个步骤:加载->验证->准备->解析->初始化->使用->卸载

 


类加载主要三步过程:加载->连接->初始化,而连接又包括验证、准备、解析三个阶段。

其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的


准备阶段:是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

(1)这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。

(2)这里所设置的初始值"通常情况"下是数据类型默认的零值(如0、0L、null、false等),比如我们定义了public static int value=111 ,
那么 value 变量在准备阶段的初始值就是 0 而不是111(初始化阶段才会复制)。特殊情况:比如给 value 变量加上了 fianl 关
键字public static final int value=111 ,那么准备阶段 value 的值就被复制为 111。

对于初始化阶段,虚拟机严格规范了有且只有5种情况下,必须对类进行初始化

(1)创建类的实例(new 的方式)。访问某个类或接口的静态变量,或者对该静态变量赋值,调用类的静态方法

(2)使用反射的方法对类进行反射调用的时候。

(3)当初始化一个类的时候,如果发现其父类还没有进行初始化,则需先触发其父类的初始化。

(4)当虚拟机启动时,用户需指定一个要加载的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。

(5)当使用 JDK 1.7 的动态语言支持时。

 

4.2 类加载器

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部
继承自java.lang.ClassLoader。

类加载器分为:启动类加载器、扩展类加载器、应用程序类加载器。

 

 

(1)启动类加载器:最顶层的加载类,负责加载 %JAVA_HOME%/jre/lib目录下rt.jar。

(2)扩展类加载器:主要负责加载目录 %JRE_HOME%/jre/lib/ext 目录下的jar包和类。

(3)应用程序类加载器 :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。


4.3 双亲委派模型 (双亲委派模型工作原理):

(1)双亲委派模型工作原理:

  在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试去加载。在类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完全这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。


(2)双亲委派模型的好处:避免类的重复加载,也保证了 Java 的核心 API 不被篡改。

 

(3)如果我们不想用双亲委派模型怎么办?

为了避免双亲委托机制,我们可以自己定义一个类加载器,然后重写 loadClass() 即可。

 

posted @ 2019-08-01 15:13  [浪子回头]  阅读(1612)  评论(0编辑  收藏  举报