前言

想成为一名优秀的Java工程师必须懂得JVM的原理,这里主要从三方面讲解:JVM类加载、JVM堆内存分配以及GC(垃圾自动回收),这里主要的还是GC回收,所以深入了解GC是必不可少的。当然,这些也是老生常谈的问题,但也是一个考验你Java功底的问题,看完这篇文章你和面试官瞎聊都没问题了。

不多说,现在就开始JVM之旅

JVM类加载

我们都知道Class文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?字节码文件到JVM使用共经历那几个过程呢?

类加载过程如下图:

系统加载Class类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。

加载

类加载过程的第一步,主要完成下面3件事情:

1、通过类的全限定名或者类的二进制字节流,JVM并没有规定字节流一定要用某种方式,可以通过压缩包(jar、war包等)、从网络上获取、动态代理生成、其他文件(JSP)、数据库、加密文件(防反编译)等。

2、将字节流所代表的静态存储结构转换为方法区的运行时数据结构

3、在内存中生成一个代表该类的Class对象,作为方法区这个类的各种数据的访问入口。

下面以student为列是怎么加载的,如下图:

加载阶段和连接阶段的部分内容是交叉进行的,加载阶段还没有结束,连接阶段可能就已经开始了。

连接

链接包括验证、准备以及解析三个阶段。

验证

验证阶段又分成4个验证过程:
1、文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,例如:是否以0xCAFEBABE开头,常量池中的常量是否有不被支持的类型,这一步就是验证字节流的文件结构是否正确。

2、元数据验证:结构正确了,这一步就是验证内容是否正确,比如:这个类是否有分类,除了java.lang.Object之外所有类都有父类,这个类是否被继承了不允许继承的类(被final修饰的类型),是不是都属于JVM所规定。

3、字节码验证:验证字节码文件方法中的Code结构,就是验证所编写的方法是否正确合理。是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。。

4、符号引用验证:校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在解析阶段中发生,验证是否有访问某些外部类、方法、字段的权限。

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配,在该阶段需要主要两点:

1、为类的静态变量分配内存并设置初始值,这里所设置的初始值"通常情况"下是数据类型默认的零值(如0、0L、null、false等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是111(初始化阶段才会复制)。特殊情况:比如给 value 变量加上了 fianl 关键字public static final int value=111 ,那么准备阶段 value 的值就被复制为 111。

2、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行。

符号引用:就是一组符号来描述目标,可以是任何字面量,class文件中常量池的constant_class_info 、constant_fieldref_info、constant_methodref_info这几个结构所存储的字符串常量。

直接引用:可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针。而实例方法,实例变量的直接引用则是从实例的头指针开始算起到这个实例变量位置的偏移量。

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量。

初始化

初始化是类加载的最后一步,也就是执行类构造器()方法的过程。所谓()方法的过程就是用户在类中定义的常量值赋值、静态代码块执行过程,在准备阶段已经对常量值设置初始值,在这里就是对常量设置用户定义的值,比如在类中存在如下一行代码:

public static final int i = 1;

在准备阶段是令i=0,而在初始化阶段则是令i=5的过程。这个过程也是静态代码块的执行过程。

对于clinit()方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为clinit()方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起死锁,并且这种死锁很难被发现。

对于初始化阶段,虚拟机严格规范了有且只有5中情况下,必须对类进行初始化:

1、当遇到 new 、 getstatic、putstatic或invokestatic 这4条直接码指令时,比如 new 一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。

2、使用 java.lang.reflect 包的方法对类进行反射调用时 ,如果类没初始化,需要触发其初始化。

3、初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。

4、当虚拟机启动时,用户需要定义一个要执行的主类 (包含 main 方法的那个类),虚拟机会先初始化这个类。

5、当使用 JDK1.7 的动态动态语言时,如果一个 MethodHandle 实例的最后解析结构为 REF_getStatic、REF_putStatic、REF_invokeStatic、的方法句柄,并且这个句柄没有初始化,则需要先触发器初始化。

JVM堆内存分配

在JVM的内存结构中,比较常见的两个区域就是堆内存和栈内存。现在有下面一个问题,以这个问题为切入点:

Java中的对象的内存分配如何保证线程安全的?

作为一个优秀的程序员我们都知道Java是一门面向对象的语言,我们在Java中使用的对象都需要被创建出来,在Java中,创建一个对象的方法有很多种,如使用new、使用反射、使用Clone方法等,但是无论如何,对象在创建过程中,都需要进行内存分配。

以最常见的new关键字举例,当我们使用new创建对象后代码开始运行后,虚拟机执行到这条new指令的时候,会先检查要new的对象对应的类是否已被加载,如果没有被加载则先进行类加载。

在类加载检查通过之后,就需要给对象进行内存分配了,分配的内存主要用来存放对象的实例变量。
在进行内存分配时,需要根据对象中的实例变量情况等信息确定需要分配的空间大小,然后从Java堆中划分出这样一块区域。

对象的内存分配过程中,主要是对象的引用指向这个内存区域,然后进行初始化操作。

但是,因为堆是全局共享的,因此在同一时间,可能有多个线程在堆上申请空间,那么,在并发场景中,如果两个线程先后把对象引用指向了同一个内存区域呢,该怎么做呢

为了解决并发问题,对象的内存分配过程就必须进行同步控制,有没有比较好的方法呢,接下来就介绍一下HotSpot虚拟机的方案(TLAB分配)。解释如下:

每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,当这部分区域用完之后,再分配新的”私有”内存。

什么是TLAB

TLAB:(Thread Local Allocation Buffer)即线程本地分配缓存区,这是一个线程专用的内存分配区域。

在 JVM中,堆被划分成两个不同的区域:新生代 (Young)、老年代 (Old),新生代 (Young) 又被划分为三个区域:Eden区、From Survivor区、To Survivor区。

TLAB是虚拟机在堆内存的Eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

虽然每个线程在初始化时都会去堆内存中申请一块TLAB,并不是说这个TLAB区域的内存其他线程就完全无法访问了,其他线程的读取还是可以的,只不过无法在这个区域中分配内存而已。TLAB的分配并不会影响垃圾的回收,也就是说对象刚开始可能通过TLAB分配内存,存放在Eden区,但是还是会被垃圾回收或者被移到Survivor Space、Old Gen等。

TLAB不一定只在Eden区分配,因为Eden区域本身就不太大,而且TLAB空间的内存也非常小,默认情况下仅占有整个Eden空间的1%。所以,如果是一些大对象是无法在TLAB直接分配,遇到TLAB中无法分配的大对象,对象可能在Eden区或者老年代等进行分配的。

还有就是上面说的TLAB是虚拟机在堆内存的Eden划分出来的一块专用空间,是线程专属的并不是说TLAB是线程独享的,在分配这个过程中上独享的,但是在读取、垃圾回收等动作上都是线程共享的。

TLAB的缺点

事实总不是完美的,虽然TLAB大大的提升了对象的分配速度,但是TLAB也不是没有问题,也有自己的缺点。因为TLAB通常很小,所以放不下大对象。

比如一个线程的TLAB空间有100KB,其中已经使用了80KB,当需要再分配一个30KB的对象时,就无法直接在TLAB中分配,遇到这种情况时,有两种处理方案:

1、如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则直接在堆内存中对该对象进行内存分配。

2、如果一个对象需要的空间大小超过TLAB中剩余的空间大小,则废弃当前TLAB,重新申请TLAB空间再次进行内存分配。

以上两个方案各有利弊,如果采用方案1,那么就可能存在着一种极端情况,就是TLAB只剩下1KB,就会导致后续需要分配的大多数对象都需要在堆内存直接分配。

如果采用方案2,也有可能存在频繁废弃TLAB,频繁申请TLAB的情况,而我们知道,虽然在TLAB上分配内存是线程独享的,但是TLAB内存自己从堆中划分出来的过程确实可能存在冲突的,所以,TLAB的分配过程其实也是需要并发控制的。而频繁的TLAB分配就失去了使用TLAB的意义。

为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。

当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配。

前面的例子中,TLAB总空间100KB,使用了80KB,剩余20KB,如果设置的refill_waste的值为25KB,那么如果新对象的内存大于25KB,则直接堆内存分配,如果小于25KB,则会废弃掉之前的那个TLAB,重新分配一个TLAB空间,给新对象分配内存。

TLAB使用的相关参数

1、TLAB功能是可以选择开启或者关闭的,可以通过设置-XX:+/-UseTLAB参数来指定是否开启TLAB分配。

2、TLAB默认是eden区的1%,可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

3、默认情况下,TLAB的空间会在运行时不断调整,使系统达到最佳的运行状态。如果需要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB来禁用,并且使用-XX:TLABSize来手工指定TLAB的大小。

4、TLAB的refill_waste也是可以调整的,默认值为64,即表示使用约为1/64空间大小作为refill_waste,使用参数:-XX:TLABRefillWasteFraction来调整。

在面试的时候如果面试官问堆是线程共享的内存区域?因为TLAB是堆内存的一部分,要分成两部分来回答:

1、在读取上确实是线程共享的

2、但是在内存分配上,是线程独享的

建议小伙伴们可以看看《实战Java虚拟机》这本书。

接下来讲的就是JVM中的重点了,

JVM 内存区域

这篇文章的内容比较多,尽量全面概括了 Java 垃圾回收机制、垃圾回收器相关内容。要搞懂垃圾回收的机制,我们首先要知道垃圾回收主要回收的是哪些数据,这些数据主要在哪一块区域,需要先了解 Java内存区域。

Java7和Java8的区别就是去掉了永久代加上了云空间。

虚拟机栈:

JVM虚拟机栈是线程私有的,每个线程都具有一个虚拟机栈,其内部保存一个个栈帧,对应着每个方法的调用。生命周期和线程生命周期相同,主要保存执行方法时的局部变量表、操作数栈、动态连接和方法返回地址等信息。

栈帧:栈帧是虚拟机栈的基本单位,栈帧的栈对应着方法的调用,方法执行时入栈,方法执行完出栈,出栈就相当于清空了数据,入栈出栈的时机很明确,所以Java虚拟机栈没有GC机制,但在栈空间不够时会出现StackOflowError错误,尽管可以通过命令调整栈大小,但不能无限制扩展,当栈无法申请到足够的内存会抛出OutOfMemoryError错误。

本地方法栈:

本地方法栈的功能和特点类似于虚拟机栈,主要区别在于虚拟机栈为虚拟机执行 Java 方法时(也就是字节码)服务,而本地方法栈为虚拟机执行本地(Native)方法时服务的。这块区域也不需要进行 GC。

程序计数器:线程独有的, 可以把它看作是当前线程执行的字节码的行号指示器,比如如下字节码内容,在每个字节码`前面都有一个数字(行号),我们可以认为它就是程序计数器存储的内容

那这些数字(指令地址)有啥用呢?

我们知道程序执行的最小单位是线程,线程A和线程B对CPU的时间片资源是抢占式争夺,任何一个线程在使用CPU的时候,其他线程就被挂起,无法使用CPU。当一个优先级更高的线程要抢占当前CPU资源时,当前线程可能未必执行完毕就被挂起。此时就需要保存现场信息,以便当前线程再次抢到CPU资源后继续执行。程序计数器的作用就是保存现场信息,每个线程都有自己的程序计数器。
需要注意的是,程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域,所以这块区域也不需要进行 GC。

本地内存:

线程共享的内存区域,Java 8中,本地内存,也是我们通常说的堆外内存,包含元空间和直接内存,注意到JVM模型图中 Java 8和 Java 8之前的JVM内存区域的区别了吗。在Java 8之前有个永久代的概念,实际上指的是 HotSpot 虚拟机上的永久代,它用永久代实现了 JVM 规范定义的方法区功能。主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受GC的管理,不过由于永久代有 -XX:MaxPermSize的上限,所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区)。

很容易造成OutOfMemoryError异常,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。所以在 Java 8中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行GC。也因此提升了性能,也就不存在由于永久代限制大小而导致的OOM异常了。综上所述,在 Java 8以后这一区域也不需要进行GC。

堆:

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。 此区域的唯一目的就是存放对象实例和数组,几乎所有对象的实例都在这分配内存。 Java堆是垃圾收集器管理的主要区域。没有错,这里是GC发生的区域,也是我们接下来重点需要分析的区域。

在垃圾收集之前GC是这样判断对象实例和数组是不是垃圾呢,有或者判断某些数据是否是垃圾的方法有哪些呢?

判断垃圾回收方法

判断哪些对象需要被回收主要有以下两种方法:

1、引用计数法

给对象添加一引用计数器,被引用一次计数器值就加 1;当引用失效时,计数器值就减 1;计数器为 0 时,对象就是不可能再被使用的,则此对象可回收。

以下例子:
String str = new String(“jack”);

str引用了右边定义的对象,所以引用次数加1

当我把str置为null

String str = new String("jack);
str = null;

由于对象没被引用,引用次数置为 0,由于不被任何变量引用,此时即被回收。

简单高效,但是缺点是无法解决对象之间相互循环引用的问题。

  public static void main(String[] args) {//定义两个对象TestA objA= new TestA();TestB objB = new TestB();//让两个对象相互引用objA.instance = objB;objB.instance = testA;//断掉外部引用objA = null;objB = null;  System.gc();
}

虽然对象A和对象B都被置为 null 了,但是由于之前它们指向的对象互相指向了对方(引用计数都为 1),所以无法回收,也正是由于无法解决循环引用的问题,所以现代虚拟机都不用引用计数法来判断对象是否应该被回收。

2、可达性分析算法

可达性算法的原理是以一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。

当一个对象不在任意一个以GC Roots为起点的引用链中,则这些对象会被判断为垃圾,会被GC回收。


如上图示,可达性分析算法解决了上述循环引用的问题。

这里需要注意一下,可达性分析仅仅只是判断对象是否可达,但还不足以判断对象是否存活或者死亡,对象finalize()方法给了对象一次机会,如下:

要判断一个对象真正死亡,还需要经历两个标记 / 筛选阶段:

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。
  2. 当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,直接进行第二次标记。
  3. 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它。

这里有个问题就是所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,因为如果一个对象在finalize()方法中执行缓慢,将很可能会一直阻塞F-Queue队列,甚至导致整个内存回收系统崩溃。

根据上面几点整合了一个流程图:

代码测试案例:

public class FinalizerTest {public static FinalizerTest object;public void isAlive() {System.out.println("I'm alive");}@Overrideprotected void finalize() throws Throwable {super.finalize();System.out.println("method finalize is running");object = this;}public static void main(String[] args) throws Exception {object = new FinalizerTest();// 第一次执行,finalize方法会自救object = null;System.gc();Thread.sleep(500);if (object != null) {object.isAlive();} else {System.out.println("I'm dead");}// 第二次执行,finalize方法已经执行过object = null;System.gc();Thread.sleep(500);if (object != null) {object.isAlive();} else {System.out.println("I'm dead");}}
}

输出结果如下:

method finalize is running
I'm alive
I'm dead

如果不重写 finalize(),输出将会是:

I'm dead
I'm dead

从执行结果可以看出:
第一次发生GC时,finalize()方法的确执行了,并且在被回收之前成功逃脱;

第二次发生GC时,由于 finalize()方法只会被 JVM 调用一次,object被回收。

那么这些GC Roots到底是什么东西呢,哪些对象可以作为GC Root呢,有以下几种

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

    1. 如下代码所示,a 是栈帧中的本地变量,当 a = null 时,由于此时 a 充当了 GC Root 的作用,a 与原来指向的实例 new Test() 断开了连接,所以对象会被回收。

  • 方法区中类静态属性引用的对象

    1. 如下代码所示,当栈帧中的本地变量 a = null 时,由于 a 原来指向的对象与 GC Root (变量 a) 断开了连接,所以 a 原来指向的对象会被回收,而由于我们给 s 赋值了变量的引用,s 在此时是类静态属性引用,充当了 GC Root 的作用,它指向的对象依然存活!
  • 方法区中常量引用的对象
    1. 如下代码所示,常量 s 指向的对象并不会因为 a 指向的对象被回收而回收.
  • 本地方法栈中JNI(即一般说的Native 方法)引用的对象
    1. java 调用非 java 代码的接口,该方法并非 Java 实现的,可能由 C 或 Python等其他语言实现的, Java 通过 JNI 来调用本地方法, 而本地方法是以库文件的形式存放的(在 WINDOWS 平台上是 DLL 文件形式,在 UNIX 机器上是 SO 文件形式)。通过调用本地的库文件的内部方法,使 JAVA 可以实现和本地机器的紧密联系,调用系统级的各接口方法。
  • 当调用 Java 方法时,虚拟机会创建一个栈桢并压入 Java 栈,而当它调用的是本地方法时,虚拟机会保持 Java 栈不变,不会在 Java 栈祯中压入新的祯,虚拟机只是简单地动态连接并直接调用指定的本地方法。

    垃圾回收算法

    上面我们知道了可以通过可达性算法来识别哪些数据是垃圾,那该怎么对这些垃圾进行回收呢。主要有以下几种方式方式:

    • 标记-清除算法
    • 复制算法
    • 标记-整理法

    标记-清除算法

    标记-清除算法是最基础的算法,为什么呢?因为后面所讲的算法部分都是从这个基本的算法不足之处演变而来。

    • 标记:先根据可达性算法遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
    • 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。

    如下图所示:

    但它存在一个很大的问题,那就是内存碎片,有以下两点:

    • 被标记的对象在内存中分布很零散,回收之后可用内存很零碎。我们知道开辟空间时,需要的是连续的内存区域,如果当一个进程需要申请一块连续的较大内存时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
    • 标记-清除过程效率不高,而且在进行GC的时候,需要停止应用程序,这会导致用户体验非常差,尤其对于交互式的应用程序来说简直是无法接受。

    那以上的内存问题怎么解决呢?假如能把未使用的 2M,1M的内存连起来就能连成一片可用空间为 3M的区域即可,怎么做呢?

    复制算法

    复制算法是对标记-清除算法在回收后出现很多内存碎片的一种改进,而且效率也有所提升。

    复制算法将内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后将之前使用过的内存空间全部清理掉。当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程 ,它会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。 清空之前的内存块,减少了大量不连续的内存碎片的产生,实现简单且运行高效。

    如下图所示:

    不过复制算法的缺点也很明显,比如给堆分配了500M内存,结果只有250M可用,空间平白无故减少了一半!这代价太大了,另外每次回收也要把存活对象移动到另一半,效率低下。

    现在的商业虚拟机都采用这种算法来回收新生代,IBM研究指出新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照1:1 的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor 。

    当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden:Survivor = 8:1,还有一个问题是当保留区的Survivor的内存大小不够承载使用中的Eden和一块Survivor区域的存活对象怎么办?此时需要依赖其他内存(老年代)进行分配担保。

    分配担保

    分配担保(Handle Promotion):如果另一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象直接通过分配担保机制进入老年代。

    发生Minor GC(只回收Eden和Survivor区)前,虚拟机检查老年代最大可用连续空间是否大于新生代所有对象总空间。如果大于,那么Minor GC确保是安全的。如果不大于,则需要查看虚拟机HandlePromotionFailure参数设置,是否允许担保失败。若允许(true),会继续检查老年代最大连续可用空间是是否大于历次晋升到老年代的对象平均大小。如果大于,会尝试一次Minor GC,尽管是有风险。(因为仅仅是历次晋升到老年代对象平均大小与老生代最大连续空间比较,如果内存小无法容纳,此时进行Minor GC会清理原本存活的对象所以是冒险的,进而需要进行Full GC)如果小于或者Handle Promotion Failure不允许冒险,那么要进行一次Full GC。

    如果老年代连续空闲空间大于历届晋升到老年代的对象的平均空间可以直接minor GC 否则 Full GC。

    标记-整理法

    复制算法是需要将对象从从内存一个区域复制到另一个区域,当发现对象存活率很高的情况下,效率很低。而且在老生代的回收中,大多不采用复制算法,没有额外的空间进行分配担保。

    标记-整理算法前两步和标记清除法一样,不同的是它在标记清除法的基础上添加了一个整理的过程 ,即将所有的存活对象都往一端移动,紧邻排列(如图示),再清理掉另一端的所有区域,这样的话就解决了内存碎片的问题。

    算法分成三个部分:

    • 标记:遍历GC Roots,然后将存活的对象标记。
    • 清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
    • 整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端内存地址以后的内存全部回收。


    标记-整理算法一方面在标记-清除算法上做了升级,解决了内存碎片的问题,也规避了复制算法只能利用一半内存区域的弊端。不过标记-整理算法,效率是唯一缺点。它对内存变动更频繁,它需要对存活对象进行标记,然后要整理存活对象内存地址,相对于复制算法效率较低。

    分代收集算法

    分代收集算法整合了以上算法,综合了这些算法的优点,最大程度避免了它们的缺点,所以是现代虚拟机采用的首选算法。

    IBM 专业研究表明,一般来说,98%的对象都是朝生夕死的,经过一次Minor GC后就会被回收),所以分代收集算法根据对象存活周期的不同将堆分成新生代和老生代(Java8以前还有个永久代),默认比例为 1 : 2,新生代又分为Eden区, from Survivor 区(简称S0),to Survivor 区(简称S1),三者的比例为 8: 1 : 1,这样就可以根据新老生代的特点选择最合适的垃圾回收算法,我们把新生代发生的 GC 称为Young GC(也叫Minor GC),老年代发生的GC 称为Old GC(也称为Full GC),人下图所示:

    那么新生代为什么要分这么多区呢?不着急,我们从头到尾,看看对象到底是怎么来的,而它又是怎么没的。

    接下来我们看一下分代垃圾收集是怎么工作的呢

    对象在新生代的分配与回收

    由以上的分析可知,有将近98%的对象是朝生夕死,所以针对这一现状,大多数情况下,对象会在新生代Eden区中进行分配。


    当Eden区没有足够空间进行分配时,虚拟机会发起一次Minor GC,Minor GC相比Major GC更频繁,回收速度也更快。

    通过Minor GC之后,Eden会被清空,Eden区中绝大部分对象会被回收,而那些无需回收的存活对象,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1, Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把Eden 区对象全部清理以释放出空间,如下图所示:

    第一步:触发Minor GC先标记Eden区的可回收对象


    第二步:将Eden区的对象移到S0区,对象年龄加一

    第三步:清空Eden区

    当触发下一次Minor GC 时,会把 Eden 区的存活对象和S0(或S1) 中的存活对象(S0或S1中的存活对象经过每次Minor GC都可能被回收)一起移到S1(Eden和S0的存活对象年龄+1), 同时清空 Eden和S0的空间,如下图所示:

    第一步:触发Minor GC先标记Eden和S0的可回收对象

    第二步:把Eden区和S0区的存活对象移到S1区,并且存活对象年龄加一

    第三步:清空Eden区和S0区

    若再触发下一次Minor GC,则重复上一步,只不过此时变成了 从Eden,S1区将存活对象复制到S0 区,每次垃圾回收, S0, S1角色互换,都是从Eden ,S0(或S1) 将存活对象移动到S1(或S0)。也就是说在 Eden区的垃圾回收我们采用的是复制算法,因为在 Eden 区分配的对象大部分在Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥Eden:S0:S1默认为8:1:1的原因),S0,S1区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。

    对象何时晋升老年代

    • 长期存活的对象:虚拟机给每个对象定义了一个对象年龄(Age)计数器。正常情况下对象会不断的在 Survivor 的 S0区与S1区之间移动,对象在Survivor区中没经历一次Minor GC,年龄就增加1岁。当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代,对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

    如下图所示:

    年龄阈值设置为 15, 当发生下一次 Minor GC 时,S0 中有个对象年龄达到 15。

    达到我们的设定阈值,晋升到老年代。

  • 大对象:当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在Eden区,会直接分配在老年代,因为如果把大对象分配在Eden区, Minor GC后再移动到S0,S1会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满S0,S1区,所以直接移到老年代。
  • 动态对象年龄判定:为了能更好地适应不同程度的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升到老年代,如果在S0(或S1)区相同年龄的对象大小之和大于S0(或S1)空间一半以上时,年龄大于或等于年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
  • Stop The World

    在新生代进行的GC叫做minor GC,在老年代进行的GC都叫major GC,Full GC同时作用于新生代和老年代,如果老年代满了,会触发 Full GC, Full GC会同时回收新生代和老年代(即对整个堆进行GC),它会导致 Stop The World。

    所谓的Stop the World机制,简称STW,即在执行垃圾收集算法(minor GC 或 Full GC)时,Java应用程序的其他所有除了垃圾收集器线程之外的线程都被挂起,如下图所示:

    一般Full GC会导致工作线程停顿时间过长,因为Full GC会清理整个堆中的不可用对象,一般要花较长的时间,如果在此 server 收到了很多请求,则会被拒绝服务!所以我们要尽量减少Full GC(Minor GC 也会造成 STW,但只会触发轻微的 STW,因为 Eden 区的对象大部分都被回收了,只有极少数存活对象会通过复制算法转移到 S0 或 S1 区,所以相对还好)。

    我们接着上面提到的一个问题,新生代为什么要分这么多区呢?

    把新生代设置成Eden, S0,S1区或者给对象设置年龄阈值或者默认把新生代与老年代的空间大小设置成 1:2 都是为了尽可能地避免对象过早地进入老年代,尽可能晚地触发Full GC。我们可以想一下如果新生代只设置Eden会发生什么呢?结果就是每经过一次Minor GC,存活对象会过早地进入老年代,那么老年代很快就会装满,很快会触发Full GC,而对象其实在经过两三次的Minor GC 后大部分都会消亡,所以有了 S0,S1的缓冲,只有少数的对象会进入老年代,老年代大小也就不会这么快地增长,也就避免了过早地触发 Full GC。

    由于Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起GC,这个时间点被称为Safe Point(顾名思义是指一些特定的位置,当线程运行到这些位置时,线程的一些状态可以被确定),如确定 GC Root 的信息等,可以使 JVM 开始安全地 GC。Safe Point 主要指的是以下特定位置:

    • 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
    • 方法返回前
    • 调用方法的call之后
    • 抛出异常的位置

    另外需要注意的是由于新生代的大部分对象经过Minor GC后会消亡,新生代中的Minor GC用的是复制算法,而在老生代由于对象比较多,占用的空间较大,使用复制算法会有较大开销,所以在老年代进行的GC一般采用的是标记整理法来进行回收。

    垃圾收集器种类

    如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。

    图中展示了7种不同分代的收集器,而它们所处区域,则表明其是属于新生代收集器还是老年代收集器。

    • 新生代收集器:Serial, ParNew, ParallelScavenge
    • 老年代收集器:CMS,Serial Old, Parallel Old
    • 同时在新老生代工作的垃圾回收器:G1

    图片中的垃圾收集器如果存在连线,则代表它们之间可以配合使用,接下来我们来看看各个垃圾收集器的具体功能。

    新生代收集器

    Serial 收集器

    Serial(串行)收集器是最基本、发展历史最悠久的收集器,它是采用复制算法的新生代收集器,曾经(JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(STW),也就是说在GC期间,此时的应用不可用。
    下图展示了Serial 收集器(老年代采用Serial Old收集器)的运行过程:

    为了消除或减少工作线程因内存回收而导致的停顿,HotSpot虚拟机开发团队在JDK 1.3之后的Java发展历程中研发出了各种其他的优秀收集器,这些将在稍后介绍。但是这些收集器的诞生并不意味着Serial收集器已经“老而无用”,实际上到现在为止,它依然是HotSpot虚拟机运行在Client模式下的默认的新生代收集器。

    在 Client 模式下,它简单而高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial 单线程模式无需与其他线程交互,减少了开销,专心做垃圾收集自然可以获得更高的单线程收集效率。

    在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。

    所以对于运行在 Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器。

    ParNew 收集器

    ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、STW、对象分配规则、回收策略等与Serial收集器完全相同,在底层上,这两种收集器也共用了相当多的代码。

    ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):

    ParNew主要工作在Server模式,我们知道服务端如果接收的请求多了,响应时间就很重要了,多线程可以让垃圾回收得更快,也就是减少了STW时间,能提升响应时间,所以是许多运行在Server模式下的虚拟机的首选新生代收集器,另一个与性能无关的原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作,CMS收集器是JDK 1.5推出的一个具有划时代意义的收集器,具体内容将在稍后进行介绍。它第一次实现了垃圾收集线程与用户线程(基本上)同时工作,它采用的是传统的GC收集器代码框架,与Serial,ParNew共用一套代码框架,所以能与这两者一起配合工作,而后文提到的Parallel Scavenge与G1收集器没有使用传统的GC收集器代码框架,而是另起炉灶独立实现的,另外一些收集器则只是共用了部分的框架代码,所以无法与CMS收集器一起配合工作。

    在多 CPU 的情况下,由于 ParNew 的多线程回收特性,毫无疑问垃圾收集会更快,也能有效地减少 STW 的时间,提升应用的响应速度。

    Parallel Scavenge 收集器

    Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间))。停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

    Parallel Scavenge收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集时间的 -XX:MaxGCPauseMillis参数及直接设置吞吐量大小的-XX:GCTimeRatio(默认99%)。

    除了以上两个参数,还可以用Parallel Scavenge收集器提供的第三个参数 -XX:UseAdaptiveSizePolicy,开启这个参数后,就不需要手工指定新生代大小,Eden 与Survivor比例(SurvivorRatio)等细节,只需要设置好基本的堆大小(-Xmx 设置最大堆),以及最大垃圾收集时间与吞吐量大小,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

    老年代收集器

    Serial Old 收集器

    Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法。此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:

    • 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
    • 另一种是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用(后文讲述)。

    它的工作流程与Serial收集器相同,这里再次给出Serial/Serial Old配合使用的工作流程图: ![在这里插入图片描述](https://img-blog.csdnimg.cn/a9b2cec6d1b549b6923684e7e00dec6c.png?x-oss-process=image/watermark,type_ZHJvaWRzYW5zZmFsbGJhY2s,shadow_50,text_Q1NETiBA6Zey5b6X5peg6IGK55qE5Lq6,size_20,color_FFFFFF,t_70,g_se,x_16) #### Parallel Old收集器 Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器。

    老年代除了Serial Old以外别无选择,所以在Parallel Old诞生以后,两者的组合由于都是多线程收集器,真正实现了「吞吐量优先」的目标,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

    Parallel Old收集器的工作流程与Parallel Scavenge相同,这里给出Parallel Scavenge/Parallel Old收集器配合使用的流程图:

    CMS 收集器

    CMS 收集器是以实现最短 STW 时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度,希望给用户最好的体验,则 CMS 收集器是个很不错的选择。

    我们之前说老年代主要用标记整理法,而 CMS 虽然工作于老年代,但采用的是标记清除算法
    CMS收集器工作的整个流程分为以下4个步骤:

    • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“STW”。
    • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
    • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“STW”。
    • 并发清除(CMS concurrent sweep)

    由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以不影响应用的正常使用,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。 通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:


    从图中可以的看到初始标记和重新标记两个阶段会发生 STW,造成用户线程挂起,不过初始标记仅标记GC Roots能关联的对象,速度很快,并发标记是进行GC Roots Tracing的过程,重新标记是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。

    但是CMS收集器远达不到完美的程度,主要有以下缺点:

    • 对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
    • CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
    • 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
    • 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。
    • 这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
    • 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
    • 空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

    但是也有优点:优点在名字上已经体现出来了:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。

    收集器(Garbage First)

    G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,主要有以下几个特点:

    • 并行与并发 G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“STW”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
    • 分代收集 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
    • 空间整合 G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
    • 可预测的停顿 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

    与 CMS 相比,它在以下两个方面表现更出色

    • 运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
    • 在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。

    为什么G1能建立可预测的停顿模型呢,主要原因在于 G1 对堆空间的分配与传统的垃圾收集器不一器,传统的内存分配就像我们前文所述,是连续的,分成新生代,老年代,新生代又分 Eden,S0,S1,如下

    在G1算法中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块(Region),每个Region是逻辑连续的一段内存,结构如下:

    在G1中,还有一种特殊的区域,叫Humongous区域,这表示这些Region存储的是巨大对象(humongous object,H-obj),如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨大对象。这些巨大对象,默认直接会被分配在年老代。那么 G1 分配成这样有啥好处呢?

    传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。

    G1的运行过程与CMS大体一致,分为以下四个步骤:

    • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
    • 并发标记( Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫 描完成以后,并发时有引用变动的对象会产生漏标问题,G1中会使用SATB(snapshot-at-the-beginning)算法来解决,后面会详细介绍。
    • 最终标记(Final Marking):对用户线程做一个短暂的暂停,用于处理并发标记阶段仍遗留下来的最后那少量的SATB记录(漏标对象)。
    • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多个收集器线程并行完成的。

    总结

    本文主要的部分简述了垃圾回收的原理与垃圾收集器的种类,整个流程讲解下来大致就是GC作用的区域是在堆中,然后介绍了引用计数法、可达性分析算法用于标记那些对象是存活的,那些对象是可回收的,接着使用垃圾回收算法(标记清除,复制算法,标记整理、分代收集算法)进行回收,最后介绍了几种垃圾回收器。相信大家看完了这篇文章之后对JVM有了更深刻的认识。

    还有就是在生产环境中我们要根据不同的场景来选择垃圾收集器组合使用,比如运行在桌面环境处于 Client模式的,则用Serial + Serial Old收集器,如果需要响应时间快,用户体验好的,则用ParNew +CMS的搭配模式,即使是最厉害的G1也需要根据吞吐量等要求适当调整相应的 JVM 参数。

    对于垃圾回收器的介绍就到这里了,如果还要非常深入的理解的话都能讲上半天。哈哈哈哈哈

深入理解JVM看这篇就够了相关推荐

  1. Bootloader详解,理解Bootloader看这篇就够了

    Bootloader作用及实现步骤 一.Bootloader作用(目的) 二.完成Boot最终目的的前提条件 三.对前提条件的详细说明 3.1.对前提条件(1)的说明: 3.2.对前提条件(2)的说明 ...

  2. React入门看这篇就够了

    2019独角兽企业重金招聘Python工程师标准>>> 摘要: 很多值得了解的细节. 原文:React入门看这篇就够了 作者:Random Fundebug经授权转载,版权归原作者所 ...

  3. uiautomation遍历windows所有窗口_万字长文!滑动窗口看这篇就够了!

    大家好,我是小浩.今天是小浩算法 "365刷题计划" 滑动窗口系列 - 整合篇.之前给大家讲解过一些滑动窗口的题目,但未作系统整理. 所以我就出了这个整合合集,整合工作中除了保留原 ...

  4. .NET Core实战项目之CMS 第二章 入门篇-快速入门ASP.NET Core看这篇就够了

    本来这篇只是想简单介绍下ASP.NET Core MVC项目的(毕竟要照顾到很多新手朋友),但是转念一想不如来点猛的(考虑到急性子的朋友),让你通过本文的学习就能快速的入门ASP.NET Core.既 ...

  5. [译]ASP.NET Core Web API 中使用Oracle数据库和Dapper看这篇就够了

    园子里关于ASP.NET Core Web API的教程很多,但大多都是使用EF+Mysql或者EF+MSSQL的文章.甚至关于ASP.NET Core Web API中使用Dapper+Mysql组 ...

  6. Spring Cloud入门,看这篇就够了!

    点击▲关注 "中生代技术"   给公众号标星置顶 更多精彩 第一时间直达 概述 首先我给大家看一张图,如果大家对这张图有些地方不太理解的话,我希望你们看完我这篇文章会恍然大悟. 什 ...

  7. 面试率 90% 的JS事件循环Event Loop,看这篇就够了!! !

    面试率 90% 的JS事件循环Event Loop,看这篇就够了!! ! 事件循环(Event Loop)大家应该并不陌生,它是前端极其重要的基础知识.在平时的讨论或者面试中也是一个非常高频的话题. ...

  8. groovy if 判断字符串_Groovy快速入门看这篇就够了

    原标题:Groovy快速入门看这篇就够了 来自:刘望舒(微信号:liuwangshuAndroid) 前言 在前面我们学习了和两篇文章,对Gradle也有了大概的了解,这篇文章我们接着来学习Groov ...

  9. 计算机老师给新生的第一堂课怎么讲,新老师如何讲好第一堂课?看这篇就够了!...

    原标题:新老师如何讲好第一堂课?看这篇就够了! 距11.3资格证笔试还有68天啦 新学期即将开始啦!在新生入学的同时,各学校也将迎来一批新教师,为校园注入新鲜的血液. 或许,作为一名新教师,也曾无数次 ...

最新文章

  1. 2022-2028年中国XPS挤塑板行业市场全景评估及产业前景规划报告
  2. vue中利用scss实现整体换肤和字体大小设置
  3. tcp时间戳 引起的网站不能访问
  4. WINDOWS故障修复台 免去重装的烦恼
  5. (4)javascript的运算符以及运算符的优先级
  6. iOS UICollectionViewCell 的拖动
  7. could not create connection to database server.] with root cause
  8. 设计模式总结一波点点
  9. 数模笔记_单变量最优化
  10. .NET 云原生架构师训练营(模块二 基础巩固 配置)--学习笔记
  11. java 抛出空指针_java - Java ServerSocket抛出空指针异常 - 堆栈内存溢出
  12. python关系运算符实例_python编程中最常用的比较运算符实例
  13. 拟合三维点平面matlab程序
  14. 贝叶斯学派与频率学派有何不同?
  15. 微星组件环境linux,微星笔记本常用系统环境组件下载集合
  16. net.sf.json.JSONObject.fromObject()方法的一个小秘密
  17. WAP网站制作(WAP网站建设)全攻略教程一
  18. ps知识的教学 day01
  19. 职工工资管理系统c语言,C++实现企业职工工资管理系统
  20. sis新地址_“这是什么梗?”,互联网上的新梗老梗如何影响你?

热门文章

  1. linux jdk8配置
  2. python横线怎么打_Python中的各种下划线
  3. 网管宝典 电脑故障排除之“八先八后”
  4. 漫谈Fintech | 券商转向综合金融服务商从移动化开始
  5. Deep Reinforcement Learning Approach to Solve Dynamic Vehicle Routing Problem with Stochastic Custom
  6. TensorFlow学习——张量运算
  7. jQuery-锚点动画
  8. delphi 字符串详解
  9. Adobe CEP插件面板结构
  10. 解决ios无法添加中文cookie的问题