这是我自己阅读《深入理解Java虚拟机--第三版》整理的一些摘要和笔记,有助于大家了解jvm的全貌,有需要的话可以去阅读原版。
我们所有的Java代码都是运行Java虚拟机上,JDK 是开发环境,但 JDK 中包含了Java的运行环境,包括虚拟机,JDK会帮我们进行内存管理,垃圾回收,线程销毁等操作,让Java程序员只专注于业务逻辑的实现,而不需要去管理内存的分配和它们的生命周期。但大部分人都不知道何为Java虚拟机,更不知道进行JVM调优,所以需要整体的进行了解和学习。
JDK最开始是由 sun 公司开发的产品,公司巅峰是 2000 亿美元市值的公司,后来经营不善,最后以 74 亿美元被 Oracle 收购。(反思:不知道公司失败的具体原因,但作为一家巨头公司,也有可能走向衰败,跟曾经的王朝历史一样,人也是如此,需要不断的反思,规划,调整自己的行进路线,实现自己的理想)
1、自动内存管理
1)、运行时数据区域
Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是 依赖用户线程的启动和结束而建立和销毁。
Java虚拟机运行时数据区
a、程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
每一条线程都有一个独立的计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为”线程私有的内存“。
b、Java虚拟机栈
与线程计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。基础数据类型(boolean、byte、char、short、int、 float、long、double)、对象引用等
如果线程请求的栈深度大于虚 拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展[2],当栈扩 展时无法申请到足够的内存会抛出OutOfMemoryError异常
c、Java堆
对于Java程序来说,Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作”GC堆(garbage collected heap)“,由于垃圾收集器大部分都是基于分代收集理论设计,所以Java堆中经常会出现新生代、老年代、永久代等等名词。
Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数 -Xmx 和 -Xms 设定)。如果在Java堆中没有内存完成实例分配,并且对也无法再扩展时,Java虚拟机将会抛出OutOfMemory Error异常。
d、方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
e、直接内存
在JDK1.4中新加入NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象左伟这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
2)虚拟机探秘
a、对象的创建
当Java虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
当类加载通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可以完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从Java堆中划分出来。
b、对象的内存布局
对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对其填充(Padding)
3)垃圾收集器与内存分配
判断对象是否存活的算法
a、引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
优缺点:引用计数法会额外占用一些内存空间,但使用简单,判断效率也很高;但主流的Java虚拟机里面没有选用引用计数算法来管理内存,需要考虑很多额外的情况,譬如单纯引用计数就很难解决对象之间相互循环引用的问题。
b、可达性分析算法
当前主流的程序语言(Java、C#)的内存管理子系统,都是通过可达性分析算法来判断对象是否存活。
这个算法的基本思路就是通过一系列称为”GC Roots“的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径为”引用链“,如果某个对象GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达,则证明此对象是不可能再被使用的。
垃圾收集算法
从如何判断对象消亡的角度出发,垃圾收集算法可以划分为”引用计数式垃圾收集“和”追踪式垃圾收集“两大类,这两类也常被称作”直接垃圾收集“和”间接垃圾收集“。
a、分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了”分代收集“的理论进行设计,有两个分带假说:
弱分代假说:绝大多数对象都是朝生夕灭的。
强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
把分带收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代和老年代两个区域。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。对象不是孤立的,对象之间会存在跨代引用。
假如要现在进行一次只局限于新生代区域内的手机,但新生代中的对象完全有可能被老年代所引用,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。理论上可行,但还存在很多问题。
跨代引用假说:跨代引用相对于同代引用来说占极少数。
b、标记—清除 算法
这是最基础的收集算法,因为后续的收集算法大多都是以清除-标记算法为基础,对其缺点进行改进而得到。
主要缺点有两个:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量的标记和清除吃的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;第二个是内存空间的碎片化问题,标记-清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
c、标记—复制 算法
标记-复制算法简称为复制算法。为了解决标记-清除算法面对大量可回收对象时执行效率低的问题。半区复制,将可用内存按容量划分为大小相等的两块,当这一块用完了,就将还存活的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存是也就不用考虑有空间碎片的复杂情况,只要一动堆顶指针,按顺序分配即可。缺点就是内存缩小为了原来的一半,空间浪费严重。
现在的商用虚拟机大多都优先采用这种收集算法去回收新生代,针对“朝生夕灭”的特点。
d、标记—整理算法
针对老年代的存亡特征,其中的标记过程仍然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都想向内存空间一端移动,然后直接清理边界以外的内存。
标记—清除算法与标记—整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判断对象是否存活。
4)经典垃圾收集器
如果说收集算法是内存回收的方法论,那垃圾收集器就是内存回收的实践者。
a、Serial 收集器
Serial收集器是最基础、历史最悠久的收集器。
缺点:这个收集器是一个单线程工作的收集器,它在进行垃圾收集时,必须暂停其它所有的工作线程,直到它收集结束。
优点:简单高效,没有线程交互的开销,在桌面应用程序中,垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一百多毫秒以内,只要不是频繁发生收集,这点停顿时间对许多用户来说是完全可以接受的。
b、ParNew 收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为包括Serial收集器可用的所有参数、收集算法、Stop the world、对象分配规则、回收策略等都与Serial收集器完全一致。
除了多线程并行收集之外,其它与Serial收集器相比并没有太多创新之处,但它却是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK 7之前的遗留系统中首选的新生代收集器。
c、Parallel Scavenge 收集器
parallel scavenge 收集器也是一款新生代收集器,它同样是基于标记-复制算法实现的收集器,也是能够并行收集的多线程收集器。
吞吐率优先的收集器。
d、Serial Old 收集器
Serial Old 是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
e、Parallel Old 收集器
Parallel Old 是 Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
f、CMS收集器
CMS(Current Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的相应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。
CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更加复杂一些,整个过程分为四个步骤,包括:
1、初始标记
2、并发标记
3、重新标记
4、并发清除
CMS收集器无法处理浮动垃圾,有可能出现”Con-Current Mode Failure“失败而导致另一次完全的”stop the world“的Full GC的产生。
g、Garbage First 收集器
Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
G1是一款主要面向服务端应用的垃圾收集器。在JDK9中取代Parallel Scavenge加Parallel Old组合,成为服务器模式下的默认垃圾收集器,
5)内存分配
a、对象优先在Eden中分配
也就是分配在新生代,但当新生代中的内存不够时会发生一次Minor GC,通过分配担保机制提前将Eden中的对象转移到老年代中。
b、大对象直接进入老年代
大对象就是指需要大量连续空间内存的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到一群”朝生夕灭“的”短命大对象“,我们写程序的时候应注意避免。
避免大对象的原因:在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能按照好它们,而当复制对象时,大对象就意味着高额的内存复制开销。
c、长期存活的对象将进入老年代
虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头中。对象通常在Eden区里诞生,如果经过第一次Minor GC后仍然存活,并且能被survivor容纳的话,该对象会被移动到survivor空间中,并且将其对象年龄设为1岁,对象在survivor中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15),就会被晋升到老年代中。可以通过参数设置。
d、动态对象年龄判断
如果在survivor空间中相同年龄所有对象大小的总和大于survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等待年龄最大参数。
e、空间分配担保
在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次的Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX: HandlePromotionFailure 参数的设置值是否允许担保失败,如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次的Minor GC是有风险的;如果小于,或者-XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次Full GC。
2、虚拟机运行子系统
Java的宣传口号:Write Once,Run Anywhere!
各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式—字节码(Byte Code)
1、类加载时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载
a、加载
关于在什么情况下需要开始类加载过程的第一个阶段”加载“,《Java虚拟机规范》中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但对于初始化阶段,《Java虚拟机规范》则是严格规定了有且只有六种情况必须立即对类进行初始化(而加载,验证,准备自然需要在此之前开始):
- 遇到new,getstatic,putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
- 使用new关键字实例化对象的时候
- 读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候
- 调用一个类型的静态方法的时候
2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK7新加入的动态语言支持时
6)当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那改接口要在其之前被初始化。
b、准备
准备阶段是正式为类中定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的。
在准备阶段,这个时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。初始值”通常情况“下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量value在准备阶段过后的初始值为0,而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令时程序被编译后。
c、解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能没有歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机的内存布局可以各不相同。(不是很明白)
直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。
d、初始化
在初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。
在准备阶段,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码的主观计划去初始化类变量和其它资源。
Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征: 继承、封装和多态。
多态的特性是重写和重载。
对效率的追求就是程序员天生的坚定信仰!
泛型
泛型本质上是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型和抽象能力。
方法重载要求方法具备不同的特征签名。
== 与 equals 的区别
基本数据类型 int、long、double等基本数据类型,只能用 “==” 来比较值大小。
但Integer,Double,Long等,使用 ”==“ 比较的是两个对象的存储地址。并且 JAVA 的 Integer 有 IntegerCache 会缓存 -128~127 之间的对象,所以不管是使用 ”==“ 或 equals 都是等价的,但如果超过了这个范围,就只能使用equals来比较,否则就算 Integer a = 321, Integer b = 321;
用 a == b 去判断相等的话返回是 false;因为这两个对象存储在不同的内存空间;使用equals的话只会比较两个值是否相等。
高效并发
并行计算中无法仅依靠寄存器来完成所有运算任务,处理器至少要与内存交互,如读取运算数据,存储运算结果等。
在多路处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存,这种系统称为共享内存多核系统。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。
Java内存模型
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
局部变量和方法参数是线程私有
Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取,赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
Java内存模型为volatile专门定义了一些特殊的访问规则。当一个变量被定义成volatile之后,它具备两项特性:
第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其它线程来说是可以立即得知的。而普通变量做不到这一点,普通变量的值在线程之间传递时均需要通过主内存来完成。但volatile并不是线程安全的,在工作内存中可能存在不一致的情况,但在每次使用前都会刷新工作内存。
Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。
synchronized代码块之间的饿操作具备原子性。
Java内存中天然的有序性:在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指”线程内似表现为串行的语义“,后半句是指”指令重排序“现象和”工作内存与主内存同步延迟“现象。
Java线程调度
线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式线程调度和抢占式线程调度。
协同式线程调度:线程的执行时间有线程本身来控制。容易造成线程堵塞
抢占式线程调度:每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。
线程安全:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其它的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
线程安全的实现方式
- 互斥同步。在Java中,最基本的互斥同步手段就是synchronized关键词,这是一种块结构的同步语法。还有一种lock,重入锁是lock接口最常见的一种实现。互斥同步也称之为阻塞同步。
- 非阻塞同步。
- 无同步方案。
网友评论