文章目录
1.什么是字节码?使用字节码的好处是什么?
java中的编译器和解释器:
java中引入了虚拟机的概念,即在机器和编译程序之间加入了一层抽象的虚拟的机器。这台机器在任何平台都提供给编译程序一个共同的接口。
编译程序只需要面向虚拟机,生成虚拟机能够理解的代码,然后由解释器来将虚拟机代码转换为特定系统的机器码执行。在java中,这种供虚拟机理解的代码叫做字节码(.class文件),它不面向任何特定的处理器,只面向虚拟机。
每一种平台的解释器是不同的,但是实现的虚拟机是相同的。java源程序经过编译器编译后变成字节码,字节码由虚拟机解释运行,虚拟机将每一条要执行的字节码送给解释器,解释器将其翻译成特定机器上的机器码,然后再特定的机器上运行。这也解释了java的编译与解释并存的特点。
采用字节码的好处:
java语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以java程序运行时比较高效,而且,由于字节码并不专对一种特定的机器,因此,java一次编译,处处运行。
2.java类加载器有哪些
- 启动类加载器(Bootstrap Class Loader): 负责加载存放在<JAVA_HOME>\lib目录,而且是java虚拟机能够识别的类库加载到虚拟机内存中,启动类加载器无法被java程序直接使用。
- 扩展类加载器(Extension Class Loader):负责加载<JAVA_HOME>\lib\ext目录中的类。
- 应用程序类加载器(Application Class Loader):又叫系统类加载器,负责加载用户类路径(ClassPath)上所有的类库,一般情况下这个就是程序中默认的类加载器。
3.双亲委派模型
如图所展示的类加载器层次关系被称为双亲委派模型,除了顶层的启动类加载器外,其余的类加载器都有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承来实现,而是使用组合关系复用父加载器的代码。
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类反馈自己无法完成这个加载请求时,子类加载器才去尝试自己去完成加载。
简单来说,就是向上委派,向下查找。
优点:
- 避免类的重复加载
- 保护程序安全,防止核心API被随意篡改
4.GC如何判断对象可以被回收
- 引用计数法: 每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以被回收。
单纯的引用计数会出现循环引用的问题,所有java虚拟机并没有采用。 - 可达性分析法: 从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象不可能再被使用,虚拟机就判断是可回收对象。
GC Roots的对象有:
- 虚拟机栈中(栈帧中的本地变量表)引用的对象,如正在运行的方法所使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如java类的引用类型静态变量。
- 在方法区中常量引用的对象,如字符串常量池里的引用。
- 在本地方法栈中JNI(即通常说的Native方法)引用的对象。
- 所有被同步锁synchronized持有的对象
判定为不可达的对象,并没有真正死亡,它只是被标记一次,随后判断此对象是否有必要执行finalize()方法(并不推荐)。
- 如果对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
- 如果对象被判定有必要执行finalize()方法,那么该对象会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法,如果对象在finalize()中重新拯救自己–重新与引用链上的任何一个对象建立关系,它将被移出"即将回收"集合。
5.JVM内存模型
jvm的构成如下图所示,jvm内存结构中的堆和方法区为多线程共享,其它为线程私有。
程序计数器
可以看作是当前线程所执行的字节码的行号指示器,通过改变这个计数器的值来选取下一条需要执行的字节码指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间互不影响,独立存储。
程序计数器是唯一一个不会出现OOM情况的内存区域,随线程的创建而创建,随线程的结束而死亡。
Java虚拟机栈
也是线程私有,生命周期于线程相同,描述的是java方法执行的线程内存模型:每个方法被执行的时候,java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
局部变量表存放了编译期可知的各种java虚拟机基本数据类型、对象引用和returnAddress类型,其所需的空间在编译期完成分配(进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的)。
这个区域规定了两类异常情况,StackOverflowError异常(线程请求的栈深度大于虚拟机所允许的深度),OutOfMemoryError异常(栈扩展时无法申请到足够内存)。
方法/函数如何调用?
java栈可类比数据结构的栈,java栈中保持的主要内存是栈帧,每一次函数调用都会有一个对应的栈帧被压入栈,每一个函数调用结束后,都会有一个栈帧被弹出。
java方法有两种返回方式:
- return语句
- 抛出异常
不管哪种返回方式都会导致栈帧被弹出。
下图为网上的栈帧、堆、方法区的关系图供理解,这里程序计数器应该在栈外(画的有误)
从图中可以可容易理解,每个线程拥有自己虚拟机栈,并且一个栈帧对应一个方法。
本地方法栈
与虚拟机栈非常类似,区别是虚拟机栈为虚拟机执行java方法服务,本地方法栈为虚拟机用到的本地(Native)方法服务。在HotSpot虚拟机中和java虚拟机栈合二为一。
本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息,也会出现StackOverflowError、OutOfMemoryError两种异常。
Java堆
java堆是所有线程共享的内存区域,在虚拟机启动时创建,此内存区的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
java堆是垃圾收集器管理的主要区域,也被称作GC堆。从垃圾回收的角度,由于现在收集器基本采用分代垃圾收集算法,所以Java堆可以分为:新生代和老年代,再细致一点有:Eden, From Survivor, To Survivor等。
如图所示,这里的永久代在JDK8版本后被彻底移除了,取而代之的是元空间,元空间直接使用内存。
Eden, 两个Survivor区都属于新生代(为了区分,两个servivor区按照顺序被命名为from和to),中间一层属于老年代。
大部分情况,对象首先在Eden区域分配,在经历一次垃圾回收后,如果对象还存活,则会进入s0或s1,并且年龄加一,当它的年龄到一定程度(默认15岁),就会晋升到老年代。
堆最容易出现OOM异常(堆无法再扩展时)。
方法区
与堆一样,多线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
6.垃圾收集算法
分代收集理论
现代垃圾收集算法,大多都遵循了分代收集理论,它建立在三个假说上:
- 弱分代假说。绝大多数对象都是朝生夕灭的。
- 强分代假说。熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说。跨代引用只占极少数。
从而可以根据对象年龄划分为新生代和老年代,虚拟机以较高频率回收新生代,较低频率回收老年代,而不用全部扫描。
至于跨代引用,新生代上建立一个全局记忆集,标识出老年代的哪一块存在跨代引用,当收集新生代时,只需要把记忆集里的加入GC Roots进行扫描。
标记-清除算法
首先标记要回收的对象,标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,回收未被标记的对象。一般来说,对于新生代,回收的占绝大多数,就标记存活对象,老年代反过来。
缺点:存在碎片化问题。算法执行后,存在大量不连续的碎片,导致大对象无法找到足够的连续内存而不得不提前触发另一次垃圾回收。
标记-复制算法
1.半区复制算法。将内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将存活的对象复制到另一块上,再把空间一次清理掉。这样就解决了碎片化问题。
可以看到,这种方式对“内存大多数都是存活对象”的时候不利,会产生大量复制,所以一般用于新生代的垃圾回收(大多数消亡)。
缺点:显而易见,可用内存缩小了一半。
IBM研表明,新生代中有98%的对象熬不过第一轮收集,所以可以不必按1:1的比例划分
2.Appel式回收。针对IBM提出的观点,新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生GC时,将Eden和Survivor中的存活对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已使用过的那块Survivor。
HotSopt虚拟机默认Eden和Survivor大小比例为8:1,也即每次新生代可以用新生代内存的90%。
如果IBM假设翻车了,剩下的那块Survivor不能容纳一次GC存活的对象,就去老年代进行分配担保。
标记-整理算法
前面说过,标记-复制算法不适合大量对象存活的情况,针对老年代这一类特殊的群体,就衍生了标记-整理算法,让所有存活对象往内存空间一端移动,直接清理掉边界以外的内存。
缺点显而易见,就是大量对象的移动,这和标记-清除算法谁更好呢?
标记-整理算法的移动操作必须stop the world(全面暂停用户程序)。
标记-清除算法导致的碎片化问题只能依赖更为复杂的内存分配器和内存访问器解决,这样会影响应用程序的吞吐量。
两者都存在很麻烦的弊端,移动则内存回收时会更复杂,不移动则内存分配会更复杂。从停顿时间来看,当然标记-清除好,从整个程序的吞吐量来看,标记-复制更划算。具体使用哪一种还得根据实际的场景来定,比如HotSpot虚拟机里面关注吞吐量的Parallel Old收集器是基于标记-整理算法的,而关注延迟的CMS收集器是基于标记-清除算法的。
其实,也可以将二者结合,让虚拟机平时大多数时间采用标记-清除算法,待有大量碎片时,再用标记-整理算法回收一次。CMS收集器就是这样做的。
7.经典垃圾收集器
本小节主要讲解Serial, ParNew, Parallel Scavnege, CMS, G1收集器。
Serial收集器
Serial收集器是一个单线程工作的收集器,“单线程”并不意味着它只会使用一个处理器或是一个线程去完成垃圾收集工作,而是强调在垃圾回收时,必须暂停其他所有工作线程,直到收集结束。
优点:简单高效,没有线程交互开销。作为客户端下的新生代收集器是不错的选择。
ParNew收集器
ParNew收集器是Serial收集器的多线程并行版本,除了使用多线程收集外,其他和Serial收集器完全一致。它是服务端下的虚拟机的首选新生代收集器(JDK7以前),主要是因为只有它能和CMS配合工作。
在垃圾收集器中,并行与并发的理解:
并行:指的是多条垃圾收集器线程之间的关系。说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程处于等待状态
并发:指的是垃圾收集器线程与用户线程之间的关系。说明同一时间垃圾收集器与用户线程都在运行。由于垃圾收集线程占用了一定的资源,此时系统吞吐量会受到一定影响。
Parallel Scavenge收集器
Parallel Scavenge收集器和前两个收集器一样,也是一款新生代收集器,也是采用复制算法,不过,它所关注的点不同,它关注吞吐量(高效率利用CPU),吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。Parallel Scavenge收集器提供了参数用来准确控制吞吐量,也可以在手工优化困难的时候,使用自适应调节策略,一切交给虚拟机也不错。
注:停顿时间和吞吐量二者不可兼得,垃圾回收器的停顿时间是缩短是以牺牲吞吐量和新生代空间为代价换取的,如果停顿时间短,就意味着系统可能会调整新生代大小,收集300MB的新生代肯定比收集500MB的新生代更快,这就导致了垃圾收集更频繁,吞吐量就降下来了。
JDK1.8默认采用Parallel Scavenge + Parallel Old收集器。
Serial Old收集器
Serial收集器的老年代版本,同样是一个单线程收集器,主要有两种用途:
- JDK1.5以及之前版本与Parallel Scavenge收集器搭配使用。
- 作为CMS收集器发生失败时的后备方案。
Parallel Old收集器
Parallel Scavenge收集器的老年代版本,支持多线程并行收集,采用标记-整理算法(关注吞吐量)。
前面说过Serial Old是JDK1.5及之前版本与Parallel Scavenge搭配使用,Serial Old在服务端很不好使(单线程),于是JDK6以后,使用了Parallel Old替换了Serial Old。这样,“吞吐量优先”收集器就有了名副其实的搭配组合。
CMS收集器
CMS是以最短回收时间为目标的老年代垃圾回收器,采用标记-清除算法。它是真正意义上的并发收集器,第一次实现了垃圾收集线程与用户线程的同时工作(基本上)。整个收集过程分为四步:
- 初始标记
- 并发标记(增量更新)
- 重新标记
- 标记清除
其中初始标记、重新标记仍然需要“Stop The World”。
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;并发标记就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程较长但不需要停顿用户线程;重新标记则是修正并发标记期间因用户线程继续运作导致标记产生变动的那一部分对象的标记记录;最后并发清除,开启用户线程,同时对未标记的区域做清除。
优点:
并发收集、低停顿。
缺点:
1.对CPU资源敏感。会因为占用了一部分线程(实际上是CPU资源)导致应用程序变慢,降低总吞吐量。
2.无法处理浮动垃圾。如果垃圾出现在标记以后,就无法在当次收集中清除。
3.会有大量空间碎片产生。
G1收集器
基于Region的堆内存布局:
把连续的java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的Region采用不同的策略去处理。
Region中有一类特殊的Humongous区域,专门存储大对象(只要大小超过了一个Region容量的一半就是大对象),大多数时候会把Humongous作为老年代看待。
运行过程:
- 初始标记
- 并发标记,用原始快照处理并发时有引用变动的对象。
- 最终标记
- 筛选回收,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划。
与CMS比较:
- G1从整体来看是基于标记-整理算法实现的收集器,但从局部(两个Region之间)来看又是基于标记复制算法实现。这意味着G1不会产生内存空间碎片。
- G1内存占用更高。因为每个Region都有一份卡表,而CMS只有一份。
8.常见调优参数
-Xms512m 初始化堆大小为512MB
-Xmx512m 堆最大为512MB
-Xmn128m 新生代大小为128MB,包括一个Eden和两个Servivor
-Xverify:none 禁止字节码验证