赞
踩
重读《深入理解Java虚拟机》,以问答的形式整理笔记。
Java在执行程序过程中,会将他所管理的内存划分为几个不同区域,有各自的用途,创建时间和销毁时间。
有这样几个区域:程序计数器、虚拟机栈、本地方法栈、堆、方法区、运行时常量池
程序计数器:一块比较小的内存空间,可以当作是当前线程所执行的字节码的行号的指示器。因为多线程下,是线程轮流切换,分配CPU的执行时间来实现的。一个内核,在任何一个确定的时刻,只能执行一条指令。所以为了线程来回切换后,还能继续从正确的位置执行指令,就需要用到程序计数器。同时,这块内存是每个线程私有的。
另外,如果线程执行的是一个Java方法,那计数器记录的是字节码指令的地址。如果执行的是一条本地方法,计数器值则为空。
这个区域是在《Java虚拟机规范》中唯一没有规定任何OOM情况的区域。
虚拟机栈:线程私有,生命周期与线程相同。每个方法执行,虚拟机都会创建一个栈帧,储存局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用到执行完毕,就对应着一个栈帧的入栈和出栈。
局部变量表:存储的是编译期可知的各种Java虚拟机基本数据类型,对象引用和 returnAddress 类型(指向一条字节码指令的地址)。
会抛出栈溢出异常和OOM异常。
本地方法栈:与虚拟机栈的作用非常类似,不同的是虚拟机栈为java方法服务,本地方法栈为本地方法(Native)服务。
堆:虚拟机管理的内存里最大的一块,被所有线程共享,在虚拟机启动的时候创建。堆唯一的目的就是存放对象实例,Java中几乎所有的对象实例都在堆分配内存。
方法区:用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
方法区中还包括运行时常量池。Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与富豪引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
详见:深入理解Java内存模型
Java内存模型:一种规范,规定了JVM如何使用计算机内存。
广义上来讲分为两个部分:JVM内存结构和JMM与线程规范
JMM主要是来控制Java之间的线程通信,决定一个线程对共享变量的写入何时对另一个线程可见(定义了线程和主内存之间的抽象关系)
JMM向开发者保证,如果程序是正确同步的,程序的执行将具有顺序一致性(顺序一致性内存模型)
保证顺序一致性的基础上(执行结果不变),给编译器和处理器最大的自由去优化(提高程序的并行度)。
手段:
内部(单线程下):happens-before原则
外部(多线程下):各种同步机制(volatile、锁、final、synchronize等)
Java对象由3部分组成,对象头,实例数据和对齐填充
Java程序通过栈上的reference数据来操作堆上的具体对象。主流的方式有两种:句柄和直接指针。
句柄:java堆划分出一块内存来作为句柄池,reference中存储对象的句柄地址。句柄中存储了对象实例数据和类型数据各自的具体地址信息。
优势是:reference中存储稳定的句柄信息,类似垃圾收集一样需要移动对象的操作,只需要改变句柄中的数据指针,而reference本身不用被修改。
直接指针:reference中存储对象实例数据地址,而对象还需要考虑如何存放对象的类型数据相关的信息。
优势是:速度快,如果只访问对象本身的话,节省了一次指针定位的时间开销。
垃圾收集需要先回答三个问题:
判断哪些内存需要回收,就是在判断哪些对象已死(不需要了),主要有两种方法:
在可达性分析中被判定为不可达到对象,不会立即被垃圾收集。发现不可达会进行第一次标记,之后会再做一次筛选,条件是这个对象是否有必要执行finalize()方法。如果对象没有覆盖过该方法,或已经被虚拟机调用执行过,都被视为没有必要执行。有必要执行该方法的对象,会被放入一个队列中,之后由一条虚拟机自动建立的,低调度优先级的线程去执行他们的finalize()方法。虚拟机只保证触发这个方法开始执行,不承诺一定会等待他运行结束。另外,finalize()方法已被官方明确声明为不推荐使用。使用try-finally是更好的方法。
JDK1.2之后,Java将引用分为了强引用、软引用、弱引用和虚引用。
强引用:最传统的引用,简单讲就是new一个对象这种引用。无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收被引用的对象。
软引用:软引用比强引用弱一点,描述还有用,但不是必须的对象。如果发现内存不够用时,会先针对软引用对象进行二次回收。如果回收完之后还是没有足够的内存,才会抛出内存溢出异常。可以用来做内存敏感的缓存。SoftReference实现。
弱引用:比软引用更弱的引用。弱引用的对象只能存活到下一次垃圾收集发生。垃圾收集器开始工作时,无论当前内存是否充足,都会回收掉弱引用对象。也可以用来做内存敏感的且不太重要的缓存。WeakReference实现。
虚引用:也叫做幻影引用,是最弱的引用关系。一个对象是否有虚引用存在,不会对其生存时间构成任何影响,也无法通过一个虚引用来取得一个对象的实例。为一个对象设置虚引用的唯一目的只是为了能在这个对象被垃圾回收器回收之前,发送一条系统通知。PhantomReference实现。
Java虚拟机规范提到可以不要求在方法区实现垃圾收集。而且方法区垃圾收集的性价比是比较低的,可回收的内存不多,而且判断什么该收集比较复杂。
方法区垃圾收集主要回收的是:废弃的常量和不再使用的类型。废弃常量与回收不再使用的对象比较类似。但判定一个类型是否废弃就比较麻烦了,需要同时满足三个条件:
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP这类频繁自定义类加载器等场景中,通常需要java虚拟机具备类型卸载能力,以保证不会对方法区造成太大的内存压力。
当前商业虚拟机的垃圾收集器,大都遵循了“分代收集”的理论进行设计。分代收集理论建立在两个假说之上:
分代收集理论:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(熬过垃圾收集的次数)分配到不同的区域中存储。
还有另一个问题:跨代引用。如果进行一次Minor GC,新生代的对象有可能被老年代引用,那么就还需要遍历整个老年代所有对象来确保可达性分析结果的正确性。这显然对性能影响很大。
我们可以从前两个假说推断出第三个假说:跨代引用对于同代引用来说只占极少数。因为存在互相引用的两个对象,应该是倾向于同时生存或者同时消亡的。比如一个老年代对象引用一个新生代对象,老年代对象难以消亡,新生代对象也不会消亡,随着年龄增长,也会晋升到老年代。
有了这个假说,我们只需要在新生代建立一个全局的数据结构(记忆集 Remembered Set),这个结构将老年代划分为若干小块,标示出哪一块内存会存在跨代引用。当发生Minor GC时,只需要将包含了跨代引用的小块内存中的老年代对象加入GC Roots进行可达性分析。
标记-清除算法
该算法分为两个阶段:标记,清除。首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象。也可以标记存活的对象,统一回收所有为标记的对象。标记过程就是对对象是否属于垃圾的判定的过程。
该算法有两个缺点:
标记-复制算法
半区复制算法:将可用内存华为大小相等的两块,每次只使用其中一块,一块用完了,就将还活着的对象复制到另一个块内存中,然后将这块内存全部清理掉。如果是老年代,会产生大量的复制对象的开销。如果是新生代,那就实现简单,运行高效。不过缺点很显然,就是内存利用率不高。
现在的商用虚拟机大都采用了这种方法的进化版:将新生代分为一块较大的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的直接关联对象开始遍历整个对象图的过程。重新标记是为了修正并发标记期间,用户线程继续运行导致的标记变动的一部分对象(增量更新),停顿时间稍长。最后是并发清除阶段。
但有三个明显的缺点:
Garbage First收集器
G1收集器。里程碑。开创了面向局部收集的思路和基于Region的内存布局形式。在延迟可控的情况下,获得尽可能高的吞吐量。
G1收集器将连续的Java堆划分为多个大小相等的独立区域,每一个Region都可以根据需要扮演新生代的Eden空间,Survivor空间或者老年空间。还有一类Humongous区域,用来存储大对象,基本等同于老年代。
G1收集器会跟踪各个Region中垃圾的价值大小,即回收所获得的空间大小和回收所需要的时间。会根据价值维护一个优先级列表,每次根据用户设定的允许收集停顿时间,来优先回收价值最大的Region。保证了G1在有限的时间内获得尽可能高的收集效率。
每个Region会维护自己的记忆集,来解决跨Region引用问题。因此会占用更多的内存(堆内存的10%~20%)。
与CMS采用增量更新算法实现并发收集不同,G1采用原始快照算法实现。
收集过程:
G1对比CMS
根据经验,6-8G以下CMS更优,以上G1更优。未来G1会逐步甩开CMS。G1的内存占用和处理器负载都要高于CMS。而且现在也无法完全替代CMS的存在。
收集器对比
常用的收集器组合:
需要根据实际情况多尝试,指导性原则是:
对于内存的考虑:
java8以前是 Parallel GC,Java9以后改为 G1 GC。
类加载会经历:加载、验证、准备、解析和初始化五个阶段。
1. 加载
2. 验证
3. 准备
为类中定义的变量(静态变量)分配内存,设置初始值。
4. 解析
将常量池内的符号引用替换为直接引用。
5. 初始化
执行类构造器()方法。由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生。(注意按顺序收集,静态语句块中语句无法访问定义在语句块之后的变量)
JVM明确规定,必须在类的首次主动使用时才能执行类的初始化
双亲委派模型的工作过程是:如果一个类加载器收到了加载类的请求,不会先自己尝试加载,而是先委派给父类加载器去加载。所以所有的类加载请求都会被传送到最顶层的类加载器中。只有当父加载器无法完成这个加载请求(没找到),才会让子加载器去尝试完成。(ClassNotFundException)
这样做的好处是类具有了一种优先层级关系,比如Java中的Object类,只会由最顶端的启动类加载器加载。开发人员无法自己新写一个Object类来替代它,从一方米娜也保证了程序的安全性。
从虚拟机的角度来看,只有两种不同的类加载器:启动类加载器和其他类加载器。
启动类加载器:由C++实现,是虚拟机自身的一部分。负责加载存放在/lib目录下,或者被-Xbootclasspath参数所制定的路径中存放的,java虚拟机能够识别的(按照名字识别,如rt.jar,名字不符的不会加载)类。无法被java程序直接引用。如果自定义类加载器时,需要委派给启动类加载器,直接使用null代替即可。
扩展类加载器:负责加载/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对自旋锁进行了优化,引入了自适应性自旋。自适应意味着自旋时间不再是固定的时间,而是由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定的。如果在同一个锁上,上一个线程刚刚自旋成功获得了锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次自旋很少的时间就获得锁,允许自旋一个相对较长的时间。如果某个锁自旋很少成功,则之后要获取这个锁时有可能直接忽略掉自旋的过程。
逃逸分析的最基本原理是:分析对象动态作用域,当一个对象在方法里被定义后,可能被外部方法引用,例如作为调用参数传递到其他方法中,这种称谓方法逃逸。还有可能被外部线程访问,这种称谓线程逃逸。不逃逸,方法逃逸,线程逃逸,称谓对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法外或线程外(也就是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法,不逃逸出线程),就可以采取不同程度的优化:
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。