深入理解《深入理解Java虚拟机》

2,501 阅读35分钟

《深入理解Java虚拟机》笔记

重读《深入理解Java虚拟机》,以问答的形式整理笔记。

Java内存区域是如何分配的?

Java在执行程序过程中,会将他所管理的内存划分为几个不同区域,有各自的用途,创建时间和销毁时间。 有这样几个区域:程序计数器、虚拟机栈、本地方法栈、堆、方法区、运行时常量池

程序计数器:一块比较小的内存空间,可以当作是当前线程所执行的字节码的行号的指示器。因为多线程下,是线程轮流切换,分配CPU的执行时间来实现的。一个内核,在任何一个确定的时刻,只能执行一条指令。所以为了线程来回切换后,还能继续从正确的位置执行指令,就需要用到程序计数器。同时,这块内存是每个线程私有的。 另外,如果线程执行的是一个Java方法,那计数器记录的是字节码指令的地址。如果执行的是一条本地方法,计数器值则为空。 这个区域是在《Java虚拟机规范》中唯一没有规定任何OOM情况的区域。

虚拟机栈:线程私有,生命周期与线程相同。每个方法执行,虚拟机都会创建一个栈帧,储存局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用到执行完毕,就对应着一个栈帧的入栈和出栈。

局部变量表:存储的是编译期可知的各种Java虚拟机基本数据类型,对象引用和 returnAddress 类型(指向一条字节码指令的地址)。 会抛出栈溢出异常和OOM异常。

本地方法栈:与虚拟机栈的作用非常类似,不同的是虚拟机栈为java方法服务,本地方法栈为本地方法(Native)服务。

:虚拟机管理的内存里最大的一块,被所有线程共享,在虚拟机启动的时候创建。堆唯一的目的就是存放对象实例,Java中几乎所有的对象实例都在堆分配内存。

方法区:用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。 方法区中还包括运行时常量池。Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与富豪引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

如何理解JMM?

Java内存模型:一种规范,规定了JVM如何使用计算机内存。 广义上来讲分为两个部分:JVM内存结构和JMM与线程规范

JMM主要是来控制Java之间的线程通信,决定一个线程对共享变量的写入何时对另一个线程可见(定义了线程和主内存之间的抽象关系)

JMM向开发者保证,如果程序是正确同步的,程序的执行将具有顺序一致性(顺序一致性内存模型)

保证顺序一致性的基础上(执行结果不变),给编译器和处理器最大的自由去优化(提高程序的并行度)。

手段: 内部(单线程下):happens-before原则 外部(多线程下):各种同步机制(volatile、锁、final、synchronize等)

这部分详细内容见另一篇文章:PRIK's BLOG —— 深入理解Java内存模型

Java对象的创建过程是怎样的??

  1. 当虚拟机遇到一条字节码new指令时,先去检查该指令的参数是否能在常量池中定位到一个类的符号引用,并且确定这个引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 类加载检查通过后,就要分配内存了。一个对象所需要的内存大小,在类加载完成后就可以确定。给对象分配内存相当于把一块完整内存快从Java堆中划分出来。这时候有两种情况:
    1. 如果堆内存是绝对完整的,那么只需要把一个指向已使用内存和未使用内存分界线的指针向一边挪一下就好了。这种分配方式成为指针碰撞。
    2. 如果堆内存不是完全整齐的,那就需要虚拟机维护一个列表,记录那块内存是可用的,有多大。分配内存时,需要在列表中找到一块足够大的内存空间划分给对象实例,并更新列表。这种方式成为空闲列表。
    选用哪种非配方式取决于堆内存是否整齐,堆内存是否整齐又取决于垃圾收集器是否有压缩整理的能力。Serial、ParNew等收集器带压缩整理过程,可以用指针碰撞的方式。CMS这种基于标记-清除算法的收集器时,理论上只能使用空闲列表的方式。
  3. 另外还需要考虑线程安全的问题。并发情况下,很多操作都是线程不安全的。解决方案有两种:
    1. CAS+失败重试,保证更新操作的原子性。
    2. 本地线程分配缓冲(Thread Local Allocation Buffer TLAB)。每个线程都有只属于自己的一小块内存,当这部分用完了,才同步锁定来分配内存。
  4. 然后需要将分配到的内存空间(除了对象头以外),初始化为零值(数据类型对应的零值),保证java对象的字段在代码中可以不赋初始值就可以使用。
  5. 设置对象头。将这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码(调用hashCode()方法时才会计算)、对象的GC分代年龄等信息,存放在对象头当中。
  6. 到这里从虚拟机的角度讲,一个对象已经创建完成了。从程序员的角度讲,对象创建才刚刚开始。因为构造函数还没有执行。这里还需要执行Class文件中的(),即构造函数,让对象按照我们的意愿构造好,一个真正可用的完整的对象才算创建完成。

Java对象由哪些部分组成?分别存储了什么信息?

Java对象由3部分组成,对象头,实例数据和对齐填充

  1. 对象头。对象头存储两部分信息,第一部分是对象自身的运行时数据,如哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳等。这部分数据长度为32bit或64bit(取决于虚拟机位数)。为了提高空间使用率,被设计为动态的数据结构。在极小的空间内存储尽量多的数据。根据对象状态不同,存储的信息意义也不同。第二部分是类型指针。就是对象指向它的类型元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
  2. 实例数据。也就是我们真正存储的对象的信息,代码中定义的各种字段等。
  3. 对齐填充。占位符。HotSpot虚拟机的自动内存管理系统要求对象的大小都必须是8字节的整数倍。对象头已经被精确设计好是8bit的一倍或者两倍,对象实例数据部分如果不齐的话,需要对齐填充来补全。

虚拟机是如何找到Java对象的位置的?有几种方式?各自的优缺点是什么?

Java程序通过栈上的reference数据来操作堆上的具体对象。主流的方式有两种:句柄和直接指针。

句柄:java堆划分出一块内存来作为句柄池,reference中存储对象的句柄地址。句柄中存储了对象实例数据和类型数据各自的具体地址信息。 优势是:reference中存储稳定的句柄信息,类似垃圾收集一样需要移动对象的操作,只需要改变句柄中的数据指针,而reference本身不用被修改。

直接指针:reference中存储对象实例数据地址,而对象还需要考虑如何存放对象的类型数据相关的信息。 优势是:速度快,如果只访问对象本身的话,节省了一次指针定位的时间开销。

如何判断一个对象是需要被回收的垃圾?垃圾收集的过程大概是怎样的?

垃圾收集需要先回答三个问题:

  1. 哪些些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

判断哪些内存需要回收,就是在判断哪些对象已死(不需要了),主要有两种方法:

  1. 引用计数法:在对象中添加一个引用计数器,每当有一个地方引用时,计数器就加一。当引用失效时,计数器就减一。任何计数器为零的对象就是不会被再使用对象。虽然这个方法原理简单,效率也高,但没有被主流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的存在。

收集器对比

收集器并行适用区域算法目标适用场景
Serial串行新生代复制算法响应速度优先单CPU下Client模式
Serial Old串行老年代标记-整理响应速度优先单CPU下Client模式/CMS后备预案
ParNew并行新生代复制算法响应速度优先多CPU下Server模式与CMS配合
Parallel Scavenge并行新生代复制算法吞吐量优先在后台运算,但交互比较少
Parallel Old并行老年代标记-整理吞吐量优先在后台运算,但交互比较少
CMS并发老年代标记-清楚响应速度优先集中在互联网网站或B/S系统服务端的Java应用中
G1并发both标记-整理+复制响应速度优先面向服务端应用,替换CMS

常用的收集器组合:

  1. Serial + Serial Old 实现单线程的低延迟垃圾回收
  2. ParNew + CMS 实现多线程的低延迟垃圾回收
  3. Parallel Scavenge + Parallel Scavenge Old 实现多线程高吞吐量垃圾回收

如何选择合适的垃圾收集器

需要根据实际情况多尝试,指导性原则是:

  1. 如果系统考虑吞吐量优先,CPU资源都用来最大程度处理业务,用 Parallel GC
  2. 如果系统考虑低延迟,每次GC时间尽量短,用 CMS GC
  3. 如果系统堆内存较大,同时希望整体来看平均 GC 时间可控,使用 G1 GC

对于内存的考虑:

  1. 4G以上,用 G1 GC 性价比比较高
  2. 如果超过 8G,达到了 16-64G 内存,那么非常推荐使用G1 GC

各版本JDK默认垃圾收集器是什么?

java8以前是 Parallel GC,Java9以后改为 G1 GC。

类加载的过程是怎样的?每个阶段都做了什么?

类加载会经历:加载、验证、准备、解析和初始化五个阶段。

1. 加载

  1. 通过一个类的权限定名来获取定义此类的二进制字节流。(花样很多,加密,网络获取,计算生成,数据库读取)如果找不到会抛出NoClassDefFoundError
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表着个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2. 验证

  1. 文件格式。是否魔数0xCAFEBABE开头,版本号虚拟机是否适配等
  2. 元数据。是否符合Java语言规范
  3. 字节码。语义是否合法
  4. 符号引用。是否缺少或禁止访问需要的外部类、方法等。 (VerifyError、ClassFormatError、UnsupportedClassVersionError)

3. 准备 为类中定义的变量(静态变量)分配内存,设置初始值。

4. 解析 将常量池内的符号引用替换为直接引用。

5. 初始化 执行类构造器()方法。由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生。(注意按顺序收集,静态语句块中语句无法访问定义在语句块之后的变量) JVM明确规定,必须在类的首次主动使用时才能执行类的初始化

什么是双亲委派模型?Java中的类加载器都有哪几种?各自的作用是什么?

双亲委派模型的工作过程是:如果一个类加载器收到了加载类的请求,不会先自己尝试加载,而是先委派给父类加载器去加载。所以所有的类加载请求都会被传送到最顶层的类加载器中。只有当父加载器无法完成这个加载请求(没找到),才会让子加载器去尝试完成。(ClassNotFundException)

这样做的好处是类具有了一种优先层级关系,比如Java中的Object类,只会由最顶端的启动类加载器加载。开发人员无法自己新写一个Object类来替代它,从一方米娜也保证了程序的安全性。

从虚拟机的角度来看,只有两种不同的类加载器:启动类加载器和其他类加载器。

启动类加载器:由C++实现,是虚拟机自身的一部分。负责加载存放在<HAVA_HOME>/lib目录下,或者被-Xbootclasspath参数所制定的路径中存放的,java虚拟机能够识别的(按照名字识别,如rt.jar,名字不符的不会加载)类。无法被java程序直接引用。如果自定义类加载器时,需要委派给启动类加载器,直接使用null代替即可。

扩展类加载器:负责加载<JAVA_HOME>/lib/ext目录中的类。或悲java.ext.dirs系统变量所制定的路径中的所有类库。

应用程序类加载器:负责加载classpath上的所有类库。可以通过ClassLoader.getSystemClassLoader()来获取应用类加载器。如果没有使用自定义类加载器,用户自定义的类都由此加载。

自定义类加载器

虚拟机是如何做锁优化的?都有哪些类型的锁?

适应性自旋 自旋虽然避免了线程切换的开销,但如果自旋时间过长,会白白占用处理器资源,带来性能的浪费。JDK6对自旋锁进行了优化,引入了自适应性自旋。自适应意味着自旋时间不再是固定的时间,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定的。如果在同一个锁上,上一个线程刚刚自旋成功获得了锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次自旋很少的时间就获得锁,允许自旋一个相对较长的时间。如果某个锁自旋很少成功,则之后要获取这个锁时有可能直接忽略掉自旋的过程。

锁消除 虚拟机即使编译器在运行时,对一些代码要求同步,但是被检测到不可能存在数据竞争的锁进行消除。主要判定依据是逃逸分析。

锁粗化 原则上,写代码时应该讲同步块的作用范围限制得尽量小。让需要同步的操作数尽量少,即使存在竞争,也能让等待的线程尽快拿到锁。但如果一系列连续操作都对同一个对象反复加锁解锁,甚至加锁操作在循环体之中,即使没有线程竞争,频繁地进行互斥同步操作也是会导致不必要的性能损耗。所以虚拟机会在这种情况下将锁的范围变大,比如循环体内的上锁操作移动到循环体外。

轻量级锁 利用对象头的 Mark Word 实现。

为了节省空间,Mark Word 在对象处于不同状态时,会存储不一样的信息。比如哈希码,GC 分代年龄等。在对象为被锁定是,有2个 bit 存储锁标志位,1个 bit 为0,表示未进入偏向模式。

当代码即将进入同步块时,如果该同步对象没有被锁定(锁标志为01),虚拟机将在当前线程的栈帧中建立一个叫 Lock Record 的空间,存储对象当前 Mark Word 的拷贝。 然后使用CAS将 Mark Word 更新为指向 Lock Record 的指针。 如果更新成功,表示该线程拥有了这个对象的锁,并将 Mark Word 中锁标志位改为“00”,表示处于轻量级锁定状态。

如果更新失败,那表示已经有别的线程获得了锁。当前线程进入自旋,继续尝试获取轻量级锁。如果一定时间之后任没有获取到,则将轻量级锁膨胀为重量级锁(修改对象头信息为重量级锁:指向重量级锁的指针+标志位10),并挂起等待。

轻量级锁释放时,也需要 CAS,将保存的 Mark Word 更新回来。如果更新成功,则同步顺利完成。如果更新失败,则表示上一步有别的线程也想要获取锁,将锁膨胀为重量级锁。所以需要在释放锁的同时,唤醒被挂起的线程。

需要注意的是,轻量级锁能够提升性能的依据是:“对于绝大部锁来说,整个同步周期内都是不存在竞争的”这一经验。通过 CAS 避免了使用互斥量的开销。如果大多数时候都存在锁竞争,那么除了原本就需要的互斥量开销外,还要多出 CAS 操作的开销,反而开销更大了。

偏向锁 目的:消除无竞争情况下的同步原语,来提高性能。 如果一个线程获取到了一个偏向锁,在没有别的线程竞争的情况下,持有偏向锁的这个线程永远都不需要再同步。 一旦有另一个线程去尝试获取这个锁,则偏向模式马上结束。如果对象未锁定,则撤销偏向(偏向位设为0),恢复到未锁定(标志位01)。如果对象已锁定,则撤销偏向并转为轻量级锁(标志位00)。 如果大多数锁都是被不同的多个线程访问,那么偏向模式实际上是多余的。可以通过参数关闭偏向锁。

什么是自旋锁?什么是适应性自旋?

自旋锁 大多数情况下,共享数据的锁定状态不会持续很久,如果某个线程请求某个锁失败,为了这个很短的时间去挂起/恢复线程不值得。可以让这个线程不要放弃处理器执行时间,执行一个忙循环(也就是自旋),来等待一会前边持有锁的线程。如果自旋一个固定时间之后还没有等到锁,就挂起线程。

适应性自旋 自旋虽然避免了线程切换的开销,但如果自旋时间过长,会白白占用处理器资源,带来性能的浪费。JDK6对自旋锁进行了优化,引入了自适应性自旋。自适应意味着自旋时间不再是固定的时间,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定的。如果在同一个锁上,上一个线程刚刚自旋成功获得了锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次自旋很少的时间就获得锁,允许自旋一个相对较长的时间。如果某个锁自旋很少成功,则之后要获取这个锁时有可能直接忽略掉自旋的过程。

什么是逃逸分析?有什么用?

逃逸分析的最基本原理是:分析对象动态作用域,当一个对象在方法里被定义后,可能被外部方法引用,例如作为调用参数传递到其他方法中,这种称谓方法逃逸。还有可能被外部线程访问,这种称谓线程逃逸。不逃逸,方法逃逸,线程逃逸,称谓对象由低到高的不同逃逸程度。

如果能证明一个对象不会逃逸到方法外或线程外(也就是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法,不逃逸出线程),就可以采取不同程度的优化:

  1. 栈上分配:在堆上分配内存,对于回收和整理来说,都是一个耗费资源的操作。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存是一个很不错的主意。对象占用的内存空间就可以随着栈帧出栈而销毁。这样垃圾收集器的压力就会少很多。
  2. 标量替换:若一个数据已经无法再分解为更小的数据来展示了,比如java虚拟机中的原始数据类型,引用类型等,这样的数据被称为标量。而java对象是标量的反义词,聚合量。根据程序访问的 情况,将启用到的成员变量,恢复为原始类型来访问,这个过程叫做标量替换。如果逃逸分析证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候可能不回去创建这个对象,而改为直接创建他的若干被这个方法使用的成员变量来替代。可以为后续进一步优化创造条件。
  3. 同步消除:线程同步是一个比较重的操作。如果逃逸分析证明一个变量不会逃逸出线程,那么这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

关注我的博客

trzoey.github.io/blog-prik/