相关链接:
- JavaVM——Java内存区域与内存溢出异常
- 为分布式做准备吧——JVM线程资源同步及交互机制
- 为分布式做准备吧——深入理解JVM
- 从头开始学Java——重新理解Java反射
什么是Java虚拟机,为什么Java被称为“平台无关的编程语言”?
Java虚拟机是一个可以执行Java字节码的虚拟机进程。Java源文件被编译成能被Java虚拟机执行的字节码文件。
什么是JIT?
JIT 是 just in time 的缩写, 也就是即时编译编译器。上面我们说过编译的流程, javac 将程序源代码编译,转换成 java 字节码,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。这样编译->再翻译的过程,其执行速度肯定会比直接执行二进制字节码程序要慢。所以为了提高执行速度,引入了 JIT 技术,在运行时 JIT 会把翻译过的机器码保存起来,以备下次使用。
JVM将重复出现很多次数,循环,重复调用的方法的字节码给保存起来.这种代码,叫热点代码,检测出热点代码的过程叫热点检测(HotSpot)。
HotSpot怎么工作的?
1、基于采样的方式探测(Sample Based Hot Spot Detection) :周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,就认为是热点方法。好处就是简单,缺点就是无法精确确认一个方法的热度。容易受线程阻塞或别的原因干扰热点探测。
2、基于计数器的热点探测(Counter Based Hot Spot Detection)。采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阀值就认为是热点方法,触发JIT编译。
HotSpot虚拟机要使用解释器与编译器并存的架构?
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。
什么是编译时、运行时?
编译时顾名思义就是正在编译的时候。那什么叫编译呢?就是编译器帮你把源代码翻译成机器能识别的代码。(当然只是一般意义上这么说,实际上可能只是翻译成某个中间状态的语言。比如Java只有JVM识别的字节码,C#中只有CLR能识别的MSIL。另外还有链接器,汇编器,为了便于理解我们可以统称为编译器)。
所谓运行时就是代码跑起来了,被装载到内存中去了。(你的代码保存在磁盘上没装入内存之前是个死家伙,只有跑到内存中才变成活的)。而运行时类型检查就与前面讲的编译时类型检查(或者静态类型检查)不一样,不是简单的扫描代码,而是在内存中做些操作,做些判断。
编译
运行
编译时运行时问题归纳
理解了这样两个概念,可以回答下面几个问题:
- “static”关键字是什么意思?Java中是否可以覆盖(override) static的方法?
“static”关键字表明一个成员变量或者是成员方法可以在没有所属的类的实例变量的情况下被访问。
Java中static方法不能被覆盖,因为方法覆盖是基于运行时动态绑定的,而static方法是编译时静态绑定的。static方法跟类的任何实例都不相关,所以概念上不适用。
- 是否可以在static环境中访问非static变量?
static变量在Java中是属于类的,它在所有的实例中的值是一样的。当类被Java虚拟机载入的时候,会对static变量进行初始化。如果你的代码尝试不用实例来访问非static的变量,编译器会报错,因为这些变量还没有被创建出来,还没有跟任何实例关联上。
- 下面的代码LineA和LineB是在哪个阶段计算的呢?
public class ConstantFolding {
static final int number1 = 5;
static final int number2 = 6;
static int number3 = 5;
static int number4= 6;
public static void main(String[ ] args) {
int product1 = number1 * number2; //line A
int product2 = number3 * number4; //line B
System.out.println(product1);
System.out.println(product2);
}
}
常量折叠是一种Java编译器使用的优化技术。由于final变量的值不会改变,因此就可以对它们优化。正确答案是,LineA是在编译时,LineB是在运行时(number3和number4是在编译时被创建,但是计算还是要等到运行时)。
使用在前置文章2中用到的javac,我们可以看到,实际上.class是这样的:
public class ConstantFolding {
static final int number1 = 5;
static final int number2 = 6;
static int number3 = 5;
static int number4 = 6;
public ConstantFolding() {
}
public static void main(String[] var0) {
byte var1 = 30;
int var2 = number3 * number4;
System.out.println(var1);
System.out.println(var2);
}
}
反射
反射也与运行时和编译时有关,所以将在接下来的内容里详细介绍。
描述Java内存模型?
线程私有
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
Java虚拟机的多线程就是通过线程轮流切换并分配处理器时间来实现的,为了线程切换后能恢复到正确的位置,每条线程都需要一个独立的程序计数器。
从上面的描述中,可能会产生程序计数器是否是多余的疑问。因为沿着指令的顺序执行下去,即使是分支跳转这样的流程,跳转到指定的指令处按顺序继续执行是完全能够保证程序的执行顺序的。假设程序永远只有一个线程,这个疑问没有任何问题,也就是说并不需要程序计数器。但实际上程序是通过多个线程协同合作执行的。
首先我们要搞清楚JVM的多线程实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。
为什么程序计数器没有规定OutOfMemoryError?
程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存。
执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的。
Java虚拟机栈描述的是Java方法执行的内存模型,每个方法执行的同时会创建一个栈帧。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
局部变量表:方法内部的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,指向堆区)。
局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。
但是,如果是成员变量,或者定义在方法外对象的引用,它们存储在堆中。
操作数栈:操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区。
动态连接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
方法出口:当一个方法开始执行后,只有2种方式可以退出这个方法 :
- 方法返回指令 : 执行引擎遇到一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。
- 异常退出 : 在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。
无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。
--------------------------------------
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
动态扩展就是在栈空间不够的时候,自动加大栈的空间,避免StackOverflow,JVM是没有实现这个功能的。
动态栈通常有两种方法:Segmented stack和Stack copying。
Segmented stack可以简单理解成一个双向链表把多个栈连接起来,一开始只分配一个栈,这个栈的空间不够时,就再分配一个,用链表一个一个连起来。
Stack copying就是在栈不够的时候,分配一个更大的栈,然后把原来的栈复制过去。
如下:
public class TestStackOverflowErrorDemo {
//栈深度统计值
private int stackLength = 1;
/**
* 递归方法,导致栈深度过大异常
*/
public void stackLeak() {
stackLength++;
stackLeak();
}
/**
* 启动方法
* 测试结果:当-Xss 180k为180k时,stackLength~=1544,随着-Xss参数变大时stackLength值随之变大
* @param args
*/
public static void main(String[] args) {
TestStackOverflowErrorDemo demo = new TestStackOverflowErrorDemo();
try {
demo.stackLeak();
} catch (Throwable e) {
System.out.println("当前栈深度:stackLength=" + demo.stackLength);
e.printStackTrace();
}
}
}
跟虚拟机栈很像,不过它是为虚拟机使用到的Native方法服务。
线程共享
堆是 JVM 所管理的最大的一块内存空间,对象实例和数组几乎都在这分配内存。
堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。堆也就是GC 收集垃圾的主要区域。
另外,堆区还包含了一个DirectByteBuffer对象,指向了堆外内存。
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式。它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆上的的 DirectByteBuffer对象 作为这块内存的引用进行操作。这样避免了在Java堆和Native堆中来回复制数据,从而提高性能。
各个线程共享的区域,主要存储加载的类字节码、class/method/field等元数据对象、static-final常量和static变量、jit编译器编译后的代码等数据。
(1)加载的类字节码:要使用一个类,首先需要将其字节码加载到JVM的内存中。至于类的字节码来源,可以多种多样,如.class文件、网络传输、或cglib字节码框架直接生成。
(2)class/method/field等元数据对象:字节码加载之后,JVM会根据其中的内容,为这个类生成Class/Method/Field等对象,它们用于描述一个类,通常在反射中用的比较多。不同于存储在堆中的java实例对象,这两种对象存储在方法区中。
(3)static-final常量、static变量:对于这两种类型的类成员,JVM会在方法区为它们创建一份数据,因此同一个类的static修饰的类成员只有一份;
(4)jit编译器的编译结果:以hotspot虚拟机为例,其在运行时会使用JIT即时编译器对热点代码进行优化,优化方式为将字节码编译成机器码。通常情况下,JVM使用“解释执行”的方式执行字节码,即JVM在读取到一个字节码指令时,会将其按照预先定好的规则执行栈操作,而栈操作会进一步映射为底层的机器操作;通过JIT编译后,执行的机器码会直接和底层机器打交道。
方法区:JDK8之前,由永久代实现,主要存放类的信息、常量池、方法数据、方法代码等;JDK8之后,取消了永久代,提出了元空间,并且常量池、静态成员变量等迁移到了堆中。
整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,其他的虚拟机实现并没有永久代这一说法。
运行时常量池是方法区的一部分。具体参考:Java常量池详解:字符串常量池、Class常量池、运行时常量池 三者关系
java中垃圾收集的方法有哪些?
标记-清除(Mark-Sweep)
这是垃圾收集算法中最基础的,根据名字就可以知道,它的思想就是标记哪些要被回收的对象,然后统一回收。这种方法很简单,但是会有两个主要问题:
- 效率不高,标记和清除的效率都很低;
- 会产生大量不连续的内存碎片,导致以后程序在分配较大的对象时,由于没有充足的连续内存而提前触发一次GC动作。
标记-整理
该算法主要是为了解决标记-清除,产生大量内存碎片的问题;当对象存活率较高时,也解决了复制算法的效率问题。它的不同之处就是在清除对象的时候现将可回收对象移动到一端,然后清除掉端边界以外的对象,这样就不会产生内存碎片了。
内存单元并不会在变成垃圾立刻回收,而是保持不可达状态,直到到达某个阈值或者固定时间长度。这个时候系统会挂起用户程序,也就是 STW,转而执行垃圾回收程序。
复制算法
为了解决效率问题,复制算法将可用内存按容量划分为相等的两部分,然后每次只使用其中的一块,当一块内存用完时,就将还存活的对象复制到第二块内存上,然后一次性清楚完第一块内存,再将第二块上的对象复制到第一块。
但是这种方式,内存的代价太高,每次基本上都要浪费一般的内存。 于是将该算法进行了改进,内存区域不再是按照1:1去划分,而是将内存划分为8:1:1三部分,较大那份内存交Eden区,其余是两块较小的内存区叫Survivor from区和Survivor to区。
- 首先,把Eden和ServivorFrom区域中存活的对象复制到ServicorTo区域(如果有对象的年龄以及达到了老年的标准,一般是15,则赋值到老年代区)
- 同时把这些对象的年龄+1(如果ServicorTo不够位置了就放到老年区)
- 然后,清空Eden和ServicorFrom中的对象;最后,ServicorTo和ServicorFrom互换,原ServicorTo成为下一次GC时的ServicorFrom区。
大对象直接进入老年代。大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
那为什么需要两个Survivor区呢,因为复制后Survivor from区虽然现在很整齐,没有碎片,当下一次进行回收时,Eden区和Survivor from区里都存在需要回收的对象,则Survivor from区也会出现碎片。
分代收集
现在的虚拟机垃圾收集大多采用这种方式,它根据对象的生存周期,将堆分为新生代和老年代。在新生代中,由于对象生存期短,每次回收都会有大量对象死去,那么这时就采用复制算法。老年代里的对象存活率较高,没有额外的空间进行分配担保,所以可以使用标记-整理 或者 标记-清除。
新生代、老年代、永久代?
Java堆内存:新生代+老年代。
方法区:永久代。
新生代
主要是用来存放新生的对象。一般占据堆的1/3空间。由于频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。
新生代又分为 Eden区、ServivorFrom、ServivorTo三个区。
- Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当Eden区内存不够的时候就会触发MinorGC,对新生代区进行一次垃圾回收。
- ServivorTo:保留了一次MinorGC过程中的幸存者。
- ServivorFrom:上一次GC的幸存者,作为这一次GC的被扫描者。
当JVM无法为新建对象分配内存空间的时候(Eden满了),Minor GC被触发。
老年代
老年代的对象比较稳定,所以MajorGC不会频繁执行。
在进行MajorGC前一般都先进行了一次MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。
MajorGC采用标记-清除算法:
- 首先扫描一次所有老年代,标记出存活的对象
- 然后回收没有标记的对象。
MajorGC的耗时比较长,因为要扫描再回收。MajorGC会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。
当老年代也满了装不下的时候,就会抛出OOM(Out of Memory)异常。
永久代
很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区进行垃圾收集的“性价比”一般比较低。
永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何一个String对象是叫做“abc”的,换句话说是没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果在这时候发生内存回收,而且必要的话,这个“abc”常量就会被系统“请”出常量池。
在Java8中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。
元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入java堆中. 这样可以加载多少类的元数据就不再由MaxPermSize控制, 而由系统的实际可用空间来控制。
如何判断一个对象是否存活?(或者GC对象的判定方法)
判断一个对象是否存活有两种方法:(一般就是下面两个流派)
所谓引用计数法就是给每一个对象设置一个引用计数器,每当有一个地方引用这个对象时,就将计数器加一,引用失效时,计数器就减一。当一个对象的引用计数器为零时,说明此对象没有被引用,也就是“死对象”,将会被垃圾回收。
优点:
- 渐进式。内存管理与用户程序的执行交织在一起,将 GC 的代价分散到整个程序。不像标记-清扫算法需要 STW (Stop The World,GC 的时候挂起用户程序)。
-
内存单元能够很快被回收。相比于其他垃圾回收算法,堆被耗尽或者达到某个阈值才会进行垃圾回收。
缺点:
- 原始的引用计数不能处理循环引用(不过针对这个问题,也除了很多解决方案,比如强引用等)。
-
维护引用计数降低运行效率。内存单元的更新删除等都需要维护相关的内存单元的引用计数,相比于一些追踪式的垃圾回收算法并不需要这些代价。
- 单元池 free list 实现的话不是 cache-friendly 的,这样会导致频繁的 cache miss,降低程序运行效率。
该算法的思想是:从一个被称为GC Roots的对象开始向下搜索,如果一个对象到GC Roots没有任何引用链相连时,则说明此对象不可用。
在java中可以作为GC Roots的对象有以下几种:
(1)虚拟机栈中引用的对象
void test() {
B b = new B(); // 引用对象b
}
(2)方法区类静态属性引用的对象
public class B {
private static A a; // 类静态属性引用对象
}
(3)方法区常量池引用的对象
public class B {
private static final A a; // 类静态属性引用对象
}
(4)本地方法栈JNI引用的对象
void test() {
JNI引用对象
}
当一个对象不可达GC Root时,这个对象并不会立马被回收,而是出于一个死缓的阶段,若要被真正的回收需要经历两次标记如果对象在可达性分析中没有与GC Root的引用链,那么此时就会被第一次标记并且进行一次筛选,筛选的条件是是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或者已被虚拟机调用过,那么就认为是没必要的。
如果该对象有必要执行finalize()方法,那么这个对象将会放在一个称为F-Queue的对队列中,虚拟机会触发一个Finalize()线程去执行,此线程是低优先级的,并且虚拟机不会承诺一直等待它运行完,这是因为如果finalize()执行缓慢或者发生了死锁,那么就会造成F-Queue队列一直等待,造成了内存回收系统的崩溃。GC对处于F-Queue中的对象进行第二次被标记,这时,该对象将被移除”即将回收”集合,等待回收。
Minor GC、Major GC和Full GC之间的区别?
三者负责的区域:
- Minor GC 是 清理 Eden区 ;
- Major GC 是 清理 老年代 ;
- Full GC 是 清理整个堆空间,包括 年轻代和老年代(和永久代)。
Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。
触发机制:
- Minor GC
当年轻代(Eden区)满时就会触发 Minor GC,这里的年轻代满指的是 Eden区满。
Survivor 满不会引发 GC 。
- Full GC
当年老代满时会引发Full GC,Full GC将会同时回收年轻代、年老代 ;
当永久代满时也会引发Full GC,会导致Class、Method元信息的卸载 。
(1)调用System.gc时,系统建议执行Full GC,但是不一定会执行 。
(2)老年代空间不足
(3)方法区空间不足
(4)通过 Minor GC 后进入老年代的空间大于老年代的可用内存
(5)由Eden区、survivor space1(From Space)区向survivor space2(To Space)区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小 。
Java GC是在什么时候,对什么东西,做了什么事情?
在什么时候:
参考上一个问题。
对什么东西:
从gc root引用链上开始搜索找不到的对象,而且finalize()第一次标记之后仍然没有复活的对象。
做了什么事情:
回答:年轻代做的是复制清理、from survivor、to survivor是干啥用的、年老代做的是标记清理、标记清理后碎片要不要整理、复制清理和标记清理有有什么优劣势。
GC中Stop the world?
为了更好的理解GC中的Stop the world案例,就必须先了解何为Stop the World方式。所谓的Stop the World机制,简称STW,即在执行垃圾收集算法时,Java应用程序的其他所有除了垃圾收集收集器线程之外的线程都被挂起。
此时,系统只能允许GC线程进行运行,其他线程则会全部暂停,等待GC线程执行完毕后才能再次运行。这些工作都是由虚拟机在后台自动发起和自动完成的,是在用户不可见的情况下把用户正常工作的线程全部停下来,这对于很多的应用程序,尤其是那些对于实时性要求很高的程序来说是难以接受的。但是有些时候对于虚拟机来说采用Stop the world机制是无法避免的,例如采用复制算法时,为了保证在复制存活的对象的时候,对象的一致性,不然要使应用程序被挂起。
但是随着java虚拟机的发展,HotSpot虚拟机团队为达到更好用户体验而一直进行着努力,不断的对垃圾收集器进行着改进,随着JDK的版本的不断更新,更好的垃圾收集器的出现,用户线程的停顿时间也在不断缩短,虽然这一时间现阶段仍然不能消除,但相信不久的未来一定会有更好的垃圾收集器被发现,从而完全达到用户对于虚拟机垃圾回收的性能要求。