深⼊理解java虚拟机pdf第三版_深⼊理解《深⼊理解Java虚拟
机》
重读《深⼊理解Java虚拟机》,以问答的形式整理笔记。
Java内存区域是如何分配的?
Java在执⾏程序过程中,会将他所管理的内存划分为⼏个不同区域,有各⾃的⽤途,创建时间和销毁时间。
有这样⼏个区域:程序计数器、虚拟机栈、本地⽅法栈、堆、⽅法区、运⾏时常量池
程序计数器:⼀块⽐较⼩的内存空间,可以当作是当前线程所执⾏的字节码的⾏号的指⽰器。因为多线程下,是线程轮流切换,分配CPU 程序计数器
的执⾏时间来实现的。⼀个内核,在任何⼀个确定的时刻,只能执⾏⼀条指令。所以为了线程来回切换后,还能继续从正确的位置执⾏指令,就需要⽤到程序计数器。同时,这块内存是每个线程私有的。
另外,如果线程执⾏的是⼀个Java⽅法,那计数器记录的是字节码指令的地址。如果执⾏的是⼀条本地⽅法,计数器值则为空。
这个区域是在《Java虚拟机规范》中唯⼀没有规定任何OOM情况的区域。
虚拟机栈:线程私有,⽣命周期与线程相同。每个⽅法执⾏,虚拟机都会创建⼀个栈帧,储存局部变量表、操作数栈、动态连接、⽅法出⼝虚拟机栈
等信息。每⼀个⽅法被调⽤到执⾏完毕,就对应着⼀个栈帧的⼊栈和出栈。
局部变量表:存储的是编译期可知的各种Java虚拟机基本数据类型,对象引⽤和 returnAddress 类型(指向⼀条字节码指令的地址)。局部变量表
会抛出栈溢出异常和OOM异常。
本地⽅法栈:与虚拟机栈的作⽤⾮常类似,不同的是虚拟机栈为java⽅法服务,本地⽅法栈为本地⽅法(Native)服务。
本地⽅法栈
堆:虚拟机管理的内存⾥最⼤的⼀块,被所有线程共享,在虚拟机启动的时候创建。堆唯⼀的⽬的就是存放对象实例,Java中⼏乎所有的对象实例都在堆分配内存。
⽅法区:⽤于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
⽅法区
⽅法区中还包括运⾏时常量池
运⾏时常量池。Class⽂件除了有类的版本、字段、⽅法、接⼝等描述信息外,还有⼀项信息是常量池表,⽤于存放编译期⽣成的各种字⾯量与富豪引⽤,这部分内容将在类加载后存放到⽅法区的运⾏时常量池中。
如何理解JMM?
详见:深⼊理解Java内存模型
Java内存模型:⼀种规范,规定了JVM如何使⽤计算机内存。
⼴义上来讲分为两个部分:JVM内存结构和JMM与线程规范
JMM主要是来控制Java之间的线程通信,决定⼀个线程对共享变量的写⼊何时对另⼀个线程可见(定义了线程和主内存之间的抽象关系)JMM向开发者保证,如果程序是正确同步的,程序的执⾏将具有顺序⼀致性(顺序⼀致性内存模型)
保证顺序⼀致性的基础上(执⾏结果不变),给编译器和处理器最⼤的⾃由去优化(提⾼程序的并⾏度)。
⼿段:
内部(单线程下):happens-before原则
外部(多线程下):各种同步机制(volatile、锁、final、synchronize等)
Java对象的创建过程是怎样的??
1. 当虚拟机遇到⼀条字节码new指令时,先去检查该指令的参数是否能在常量池中定位到⼀个类的符号引⽤,并且确定这个引⽤代表的
类是否已被加载、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。
2. 类加载检查通过后,就要分配内存了。⼀个对象所需要的内存⼤⼩,在类加载完成后就可以确定。给对象分配内存相当于把⼀块完整
内存快从Java堆中划分出来。这时候有两种情况:
1. 如果堆内存是绝对完整的,那么只需要把⼀个指向已使⽤内存和未使⽤内存分界线的指针向⼀边挪⼀下就好了。这种分配⽅式称
为指针碰撞。
2. 如果堆内存不是完全整齐的,那就需要虚拟机维护⼀个列表,记录那块内存是可⽤的,有多⼤。分配内存时,需要在列表中到
⼀块⾜够⼤的内存空间划分给对象实例,并更新列表。这种⽅式称为空闲列表。
3. 选⽤哪种⾮配⽅式取决于堆内存是否整齐,堆内存是否整齐⼜取决于垃圾收集器是否有压缩整理的能⼒。Serial、ParNew等收
集器带压缩整理过程,可以⽤指针碰撞的⽅式。CMS这种基于标记-清除算法的收集器时,理论上只能使⽤空闲列表的⽅式。3. 另外还需要考虑线程安全的问题。并发情况下,很多操作都是线程不安全的。解决⽅案有两种:
1. CAS+失败重试,保证更新操作的原⼦性。
2. 本地线程分配缓冲(Thread Local Allocation Buffer TLAB)。每个线程都有只属于⾃⼰的⼀⼩块内存,当这部分⽤完了,才
同步锁定来分配内存。
4. 然后需要将分配到的内存空间(除了对象头以外),初始化为零值(数据类型对应的零值),保证java对象的字段在代码中可以不赋
初始值就可以使⽤。
5. 设置对象头。将这个对象是哪个类的实例,如何才能到类的元数据信息,对象的哈希码(调⽤hashCode()⽅法时才会计算)、对象
的GC分代年龄等信息,存放在对象头当中。
6. 到这⾥从虚拟机的⾓度讲,⼀个对象已经创建完成了。从程序员的⾓度讲,对象创建才刚刚开始。因为构造函数还没有执⾏。这⾥还
需要执⾏Class⽂件中的(),即构造函数,让对象按照我们的意愿构造好,⼀个真正可⽤的完整的对象才算创建完成。
Java对象由哪些部分组成?分别存储了什么信息?
Java对象由3部分组成,对象头,实例数据和对齐填充
对象头。对象头存储两部分信息,第⼀部分是对象⾃⾝的运⾏时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线1. 对象头
程ID,偏向时间戳等。这部分数据长度为32bit或64bit(取决于虚拟机位数)。为了提⾼空间使⽤率,被设计为动态的数据结构。在极⼩的空间内存储尽量多的数据。根据对象状态不同,存储的信息意义也不同。第⼆部分是类型指针。就是对象指向它的类型元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据。也就是我们真正存储的对象的信息,代码中定义的各种字段等。
2. 实例数据
对齐填充。占位符。HotSpot虚拟机的⾃动内存管理系统要求对象的⼤⼩都必须是8字节的整数倍。对象头已经被精确设计好是8bit 3. 对齐填充
的⼀倍或者两倍,对象实例数据部分如果不齐的话,需要对齐填充来补全。
虚拟机是如何到Java对象的位置的?有⼏种⽅式?各⾃的优缺点是什么?
Java程序通过栈上的reference数据来操作堆上的具体对象。主流的⽅式有两种:句柄和直接指针。
句柄:java堆划分出⼀块内存来作为句柄池,reference中存储对象的句柄地址。句柄中存储了对象实例数据和类型数据各⾃的具体地址信句柄
息。
优势是:reference中存储稳定的句柄信息,类似垃圾收集⼀样需要移动对象的操作,只需要改变句柄中的数据指针,⽽reference本⾝不⽤被修改。
直接指针:reference中存储对象实例数据地址,⽽对象还需要考虑如何存放对象的类型数据相关的信息。
直接指针
优势是:速度快,如果只访问对象本⾝的话,节省了⼀次指针定位的时间开销。
如何判断⼀个对象是需要被回收的垃圾?垃圾收集的过程⼤概是怎样的?
垃圾收集需要先回答三个问题:
1. 哪些些内存需要回收?
2. 什么时候回收?
3. 如何回收?
判断哪些内存需要回收,就是在判断哪些对象已死(不需要了),主要有两种⽅法:
引⽤计数法:在对象中添加⼀个引⽤计数器,每当有⼀个地⽅引⽤时,计数器就加⼀。当引⽤失效时,计数器就减⼀。任何计数器为1. 引⽤计数法
java基础教程第三版pdf零的对象就是不会被再使⽤对象。虽然这个⽅法原理简单,效率也⾼,但没有被主流java虚拟机所采⽤。原因是这个看似简单的算法有很多例外的情况需要考虑,需要配合⼤量的额外处理才能保证正确⼯作。⽐如单纯的引⽤计数很难解决对象之间互相循环引⽤的问题。
2. 可达性分析算法
可达性分析算法:基本思路是,通过⼀系列称为“GC Roots”的根对象作为起始节点,根据引⽤关系向下搜索,如果某个对象没有任何⼀条路能够达到GC Roots,那么就说明从GC Roots到这个对象不可达,依此证明这个对象不再被使⽤。
在可达性分析中被判定为不可达到对象,不会⽴即被垃圾收集。发现不可达会进⾏第⼀次标记,之后会再做⼀次筛选,条件是这个对象是否有必要执⾏finalize()⽅法。如果对象没有覆盖过该⽅法,或已经被虚拟机调⽤执⾏过,都被视为没有必要执⾏。有必要执⾏该⽅法的对象,会被放⼊⼀个队列中,之后由⼀条虚拟机⾃动建⽴的,低调度优先级的线程去执⾏他们的finalize()⽅法。虚拟机只保证触发这个⽅法开始执⾏,不承诺⼀定会等待他运⾏结束。另外,finalize()⽅法已被官⽅明确声明为不推荐使⽤。使⽤try-finally是更好的⽅法。
Java中都有哪些引⽤类型?
JDK1.2之后,Java将引⽤分为了强引⽤、软引⽤、弱引⽤和虚引⽤。
强引⽤:最传统的引⽤,简单讲就是new⼀个对象这种引⽤。⽆论任何情况下,只要强引⽤关系存在,垃圾收集器就永远不会回收被引⽤的强引⽤
对象。
软引⽤:软引⽤⽐强引⽤弱⼀点,描述还有⽤,但不是必须的对象。如果发现内存不够⽤时,会先针对软引⽤对象进⾏⼆次回收。如果回收软引⽤
完之后还是没有⾜够的内存,才会抛出内存溢出异常。可以⽤来做内存敏感的缓存。SoftReference实现。
弱引⽤:⽐软引⽤更弱的引⽤。弱引⽤的对象只能存活到下⼀次垃圾收集发⽣。垃圾收集器开始⼯作时,⽆论当前内存是否充⾜,都会回收弱引⽤
掉弱引⽤对象。也可以⽤来做内存敏感的且不太重要的缓存。WeakReference实现。
虚引⽤:也叫做幻影引⽤,是最弱的引⽤关系。⼀个对象是否有虚引⽤存在,不会对其⽣存时间构成任何影响,也⽆法通过⼀个虚引⽤来取虚引⽤
得⼀个对象的实例。为⼀个对象设置虚引⽤的唯⼀⽬的只是为了能在这个对象被垃圾回收器回收之前,发送⼀条系统通知。PhantomReference实现。
⽅法区的垃圾回收是怎样的?
Java虚拟机规范提到可以不要求在⽅法区实现垃圾收集。⽽且⽅法区垃圾收集的性价⽐是⽐较低的,可回收的内存不多,⽽且判断什么该收集⽐较复杂。
⽅法区垃圾收集主要回收的是:废弃的常量和不再使⽤的类型。废弃常量与回收不再使⽤的对象⽐较类似。但判定⼀个类型是否废弃就⽐较⿇烦了,需要同时满⾜三个条件:
1. 该类所有的实例都被回收,java堆中不存在该类及任何派⽣⼦类的实例
2. 加载该类的类加载器已经被回收
3. 该类对应的java.lang.Classs对象没有在任何地⽅被引⽤,⽆法通过反射访问。
但满⾜条件也只是允许回收,具体是否回收由参数控制。
在⼤量使⽤反射、动态代理、CGLib等字节码框架,动态⽣成JSP这类频繁⾃定义类加载器等场景中,通常需要java虚拟机具备类型卸载能⼒,以保证不会对⽅法区造成太⼤的内存压⼒。
什么是分代收集?
当前商业虚拟机的垃圾收集器,⼤都遵循了“分代收集”的理论进⾏设计。分代收集理论建⽴在两个假说之上:弱分代假说:绝⼤多数对象都是朝⽣⼣灭的
1. 弱分代假说
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
2. 强分代假说
分代收集理论:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾收集的次数)分配到不同的区域中存储。分代收集理论
1. 如果⼀个区域⼤多都是朝⽣⼣灭的对象,那么这个区域回收时只需要关注少数可以存活下来的对象,⽽不是去标记很多需要回收的对
象,这样就能以较低的代价回收⼤量的空间。
2. 如果⼀个区域⼤多都是难以消亡的对象,那么就可以⽤较低的频率来收集这个区域,同时兼顾了垃圾收集的时间开销和内存空间的有
效利⽤。
跨代引⽤。如果进⾏⼀次Minor GC,新⽣代的对象有可能被⽼年代引⽤,那么就还需要遍历整个⽼年代所有对象来确保还有另⼀个问题:跨代引⽤
可达性分析结果的正确性。这显然对性能影响很⼤。
第三个假说:跨代引⽤对于同代引⽤来说只占极少数。因为存在互相引⽤的两个对象,应该是倾向于同时⽣我们可以从前两个假说推断出第三个假说
存或者同时消亡的。⽐如⼀个⽼年代对象引⽤⼀个新⽣代对象,⽼年代对象难以消亡,新⽣代对象也不会消亡,随着年龄增长,也会晋升到⽼年代。
有了这个假说,我们只需要在新⽣代建⽴⼀个全局的数据结构(记忆集 Remembered Set),这个结构将⽼年代划分为若⼲⼩块,标⽰出哪⼀块内存会存在跨代引⽤。当发⽣Minor GC时,只需要将包含了跨代引⽤的⼩块内存中的⽼年代对象加⼊GC Roots进⾏可达性分析。
有哪些常见的垃圾收集算法?各⾃的原理是怎样的?
标记-清除算法
该算法分为两个阶段:标记,清除。⾸先标记出所有需要回收的对象,标记完成后,统⼀回收掉所有被标记的对象。也可以标记存活的对象,统⼀回收所有为标记的对象。标记过程就是对对象是否属于垃圾的判定的过程。
该算法有两个缺点:
1. 执⾏效率不稳定。执⾏时间随着对象的数量增长⽽增长。
2. 内存空间碎⽚化。标记清除之后会留下⼤量不连续的内存碎⽚。碎⽚空间太多会导致之后分配⼤内存对象的时候,因为不到⼀块连
续的⾜够⼤的内存,⽽不得不提前触发另⼀次垃圾收集动作。
标记-复制算法
半区复制算法:将可⽤内存华为⼤⼩相等的两块,每次只使⽤其中⼀块,⼀块⽤完了,就将还活着的对象复制到另⼀个块内存中,然后将这块内存全部清理掉。如果是⽼年代,会产⽣⼤量的复制对象的开销。如果是新⽣代,那就实现简单,运⾏⾼效。不过缺点很显然,就是内存利⽤率不⾼。
现在的商⽤虚拟机⼤都采⽤了这种⽅法的进化版:将新⽣代分为⼀块较⼤的Eden空间和两块较⼩的Survivor空间。每次分配内存只使⽤Eden和其中⼀块Survivor。发⽣垃圾收集时,将Eden和Survivor中仍然存活的对象⼀次性复制到另⼀块Survivor空间上,然后清理掉他们。HotSpot默认的⼤⼩⽐例时Eden:Survivor = 8 : 1。为了避免⼀些情况下,Survivor不⾜以容纳存活的对象,还会依赖其他区域内存(⽼年代)进⾏分配担保。
标记-整理算法
⽼年代⼀般不会选择标记复制算法。因为有⼤量的复制开销,还需要有额外的分配担保。针对⽼年代
对象的存亡特征,标记-整理算法出现了:标记过程与标记-清除算法⼀样,但标记完成后,让所有存活的对象都向内存空间的⼀端移动,然后直接清理掉边界以外的内存。
但移动对象也是⼀个负担很重的操作,如果不移动,⼜会有碎⽚空间的问题,或者依赖更为复杂的内存分配器和内存访问器来解决。
⼀种解决办法是,平时⼤多是时候采⽤标记清除算法,知道内存空间碎⽚化程度太⼤,影响到⼤内存对象分配时,再进⾏⼀次标记-整理算法。
分代收集理论中,是如何解决跨代引⽤问题的?什么是记忆集?什么是卡表?卡表⼜是如何维护的?
记忆集的数据结构,来避免将整个⽼年代加⼊GC Roots扫描范围。
为了解决跨代引⽤带来的问题,垃圾收集器在新⽣代中建⽴了名为记忆集
记忆集是⼀种记录从⾮收集区域指向收集区域的指针集合的数据结构。考虑到存储和维护成本,没必要将记忆集的精度精确到每⼀个指针。
卡表(Card 每个记录精确到⼀⼩块内存区域,该区域内有⼀个或⼀些对象含有跨代指针。这样的实现⽅式叫做卡表
最终选择了卡精度:每个记录精确到⼀⼩块内存区域,该区域内有⼀个或⼀些对象含有跨代指针
Table)。底层数据结构为⼀个字节数组。每⼀个元素都对应着其表⽰的内存区域中⼀块特定⼤⼩的内存快。这个内存快叫做卡页。每个卡页中有多个对象,只要有⼀个对象含有跨代指针,就标记为1,其他为0。垃圾收集时,只要筛选出卡表中标记为1的元素,就能轻易到那些卡页内存快包含跨代指针,把他们加⼊GC Roots中⼀起扫描即可。
那么卡表的状态⼜是如何维护的呢?
HotSpot虚拟机是通过写屏障技术维护卡表的。写屏障可以看作是虚拟机层⾯对“引⽤类型字段赋值”这个操作的AOP切⾯。在引⽤对象赋值时,产⽣⼀个环绕通知,可以利⽤这个特性来维护卡表。
可达性分析在并发的环境下是如何保证正确的?
在可达性分析时,必须在⼀致性快照的基础上对对象图进⾏遍历。否则会有可能导致将原本应该存活的对象标记为已消亡。
⽐如对⼀个被标记为死亡的对象A引⽤的对象B进⾏分析时,标记B为死亡,但之后B⼜被⼀个已经扫描过的,标记为存活的对象C引⽤它,这时不会重新再扫描这个存活的C对象,所以这个本应该存活的对象B就会被垃圾收集了。
要解决对象消失的问题,有两种⽅案。
增量更新:当被扫描过且标记为存活的对象插⼊新的指向被标记为死亡的对象的引⽤关系时,将这个引⽤记录下来,等并发扫描结束后,再增量更新
将这些记录过的引⽤关系中的存活对象为根,重新扫描⼀次。
原始快照
原始快照:⼀个被访问过,但还没有完全确定存活(不是所有引⽤都遍历了)的对象,如果赋值器要删除它引⽤的还没有被扫描到的对象的引⽤关系,就暂时记录下来,等扫描结束后,重新以该对象为根再扫描⼀次。
常见的垃圾收集器有哪些?都是如何⼯作的?
Serial收集器
最基础,最历史悠久的收集器,采⽤标记-复制算法。早期新⽣代收集器的唯⼀选择。单线程⼯作,⽽且当进⾏垃圾收集时,必须暂停其他所有⼯作线程,直到收集结束。
但他有简单⾼效的优点,⽽且是所有垃圾收集器中额外内存消耗最⼩的,是运⾏在客户端模式下的默认新⽣代收集器。另外对于单核处理器来说,单线程没有线程切换的开销,收集效率反⽽更⾼。它对于运⾏在客户端模式下(桌⾯应⽤)有着较好的应⽤。对于⼩内存的新⽣代来说,垃圾收集停顿时间完全可以控制在⼗⼏到⼏⼗毫秒。
Serial Old 收集器
Serial收集器的⽼年代版本。单线程。使⽤标记-整理算法。也是主要提供客户端模式下的虚拟机使⽤。在服务端也有使⽤:JDK5之前版本中搭配Parallel Scavenge收集器使⽤,还有就是作为CMS的备⽤收集器,并发收集发⽣Concurrent Mode Failure时使⽤。
ParNew收集器
Serial收集器的多线程版本,对于多核处理器来说,显然是要优于Serial收集器的。
Parallel Scavenge收集器
采⽤标记-复制算法的新⽣代收集器。多线程。关注的重点是达到⼀个可控制的吞吐量,⼜叫做吞吐量优先收集器。有参数可以设置为⾃动根据系统运⾏情况,设置合适的新⽣代⼤⼩、Eden与Survivor区域的⽐例、晋升⽼年代对象的⼤⼩等参数,来达到合适的停顿时间或者最⼤的吞吐量(⾃适应调节)。
如果使⽤者对收集器⼿动优化存在困难,那么这个模式是⼀个不错的选择。
Parallel Od收集器
paralllel收集器的⽼年代版本。多线程。标记-整理算法。同样注重吞吐量。
CMS收集器
以最短回收停顿时间为⽬标,系统停顿时间尽量短来给⽤户最佳的交互体验。收集过程分为四个步骤:1. 初始标记 -> 2. 并发标记 -> 3. 重新标记 -> 4. 并发清除。初始标记和重新标记需要 Stop The World。初始标记只是标记⼀下GC Roots能直接关联到的对象,速度很快。并发标记是从GC Roots的直接关联对象开始遍历整个对象图的过程。重新标记是为了修正并发标记期间,⽤户线程继续运⾏导致的标记变动的⼀部分对象(增量更新),停顿时间稍长。最后是并发清除阶段。
但有三个明显的缺点:
1. CMS收集器对处理器资源⾮常敏感。占⽤了⼀部分CPU计算能⼒,所以导致总吞吐量降低。
2. ⽆法收集浮动垃圾有可能导致⼀次Full GC。并发标记和并发清除阶段,系统还是正常运⾏,所以需要预留出⼀部分内存来给系统使
⽤。如果预留的内存⽆法满⾜程序新分配内存的需要,就会出现并发失败(Concurrent Mode Failure)。这时虚拟机会启⽤后备⽅案,冻结⽤户线程,临时启⽤Serial Old收集器来重新进⾏⽼年代的垃圾收集。这样会停顿更长的时间。
3. 因为是标记-清除算法,收集结束时会产⽣⼤量碎⽚空间。有时会提前出发Full GC。
Garbage First收集器
G1收集器。⾥程碑。开创了⾯向局部收集的思路和基于Region的内存布局形式。在延迟可控的情况下,获得尽可能⾼的吞吐量。
G1收集器将连续的Java堆划分为多个⼤⼩相等的独⽴区域,每⼀个Region都可以根据需要扮演新⽣代的Eden空间,Survivor空间或者⽼年空间。还有⼀类Humongous区域,⽤来存储⼤对象,基本等同于⽼年代。
G1收集器会跟踪各个Region中垃圾的价值⼤⼩,即回收所获得的空间⼤⼩和回收所需要的时间。会根据价值维护⼀个优先级列表,每次根据⽤户设定的允许收集停顿时间,来优先回收价值最⼤的Region。保证了G1在有限的时间内获得尽可能⾼的收集效率。
每个Region会维护⾃⼰的记忆集,来解决跨Region引⽤问题。因此会占⽤更多的内存(堆内存的10%
~20%)。
与CMS采⽤增量更新算法实现并发收集不同,G1采⽤原始快照算法实现。
收集过程:
初始标记: 标记GC Roots能够直接关联到的对象
并发标记 :并发进⾏可达性分析
最终标记 :短暂停顿,处理并发标记结束时遗留的少量对象(原始快照)
筛选回收:更新Region统计数据,根据价值和回收成本机型排序,并依据⽤户期望的停顿时间来制定回收计划。选择任意多个Region 构成回收集,将存活的对象复制到空的Region中,在清除掉整个旧的Region空间。移动对象的过程必须暂停⽤户线程。并且由多条收集器线程并发执⾏。
G1对⽐CMS
根据经验,6-8G以下CMS更优,以上G1更优。未来G1会逐步甩开CMS。G1的内存占⽤和处理器负载都要⾼于CMS。⽽且现在也⽆法完全替代CMS的存在。
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论