发号器
发号器几乎是最简单的一个中间件了,它旨在生成一个全局唯一ID,用于在业务领域内标识一个对象。
序列化(Serialization):事件A必须在事件B之前发生。
互斥(Mutual exclusion):事件A和B不能同时发生。
线程A:
1 | do sth... |
线程B:
1 | wait for A |
B会等待A发来消息后再执行后续的指令。
1、并发写
线程A
1 | x = 5 |
线程B
1 | x = 7 |
这两个线程并发执行,最后打印出来的结果不确定是5还是7。
2、并发更新
线程A:
1 | count = count + 1 |
线程B:
1 | count = count + 1 |
两个线程的操作都是读后写,可能就会发生同时读出旧值然后都+1,最终结果并没有+2的情况。
3、通过发消息互斥执行
通过发消息保证共享变量的安全更新。
支持PV操作,P原子地减少信号量值,当值为0时阻塞,V原子地增加信号量值。
信号量的优点:
一个线程发消息给另一个线程告知某件事情的发生。
线程A:
1 | statement a1 |
线程B:
1 | sem.wait() |
只有A signal发出消息后,B才能从wait离开继续执行。
在Java中,发信号的功能可以通过Object的wait/notify、Lock的Condition、LockSupport、Semaphore实现。
不知道怎么翻译,叫做汇聚?作者给出的是类似下面这样的例子:
线程A:
1 | statement a1 |
线程B:
1 | statement b1 |
注意signal和wait不要写反了,写反了会死锁。
在Java中,可以通过CyclicBarrier
实现。
使用信号量可以实现互斥量,实际上互斥量可以看作Semaphore(1)
,使用以下代码就可以实现两个线程的互斥执行:
线程A:
1 | mutex.wait() |
线程B:
1 | mutex.wait() |
上边mutex包围的代码就称为临界区代码(critical section)。
将上边的互斥量泛化,我们让多个线程可以同时执行一块临界区代码。
其实就是用Semaphore(n)
就可以实现n个线程同时执行了。
只有所有线程都到达某个位置才能一块继续执行下去,栅栏可以通过以下代码实现(有bug,会出现死锁):
1 | count = 0 |
1 | rendezvous // 汇聚 |
如果直接拿来执行,容易发现只有1个线程能执行下去,因为:
barrier.wait
后barrier的值变为-4;barrier.signal
释放了1个,barrier的值变为-3,此时只有一个线程被放过去了,另外还有3个线程仍阻塞,且第5个线程随后也会进入阻塞状态。修改后的代码如下:
1 | rendezvous // 汇聚 |
分析问题时:
两个线程交替打印数组的功能,虽然比较简单,但是面试时问的还蛮多的,如果用Semaphore实现会比较简单,用Java的wait/notify或Condition实现则会稍微麻烦一点。
1 | public class OneByOneTest { |
1 | public class OneByOneConditionTest { |
1 | public class WaitNotiftTest { |
解决生产者/消费者问题需要维护一个队列,生产者向队列添加,消费者从队列获取,同步问题出现在队列为空或满的情况,因此我们需要对队列进行同步化。
为了简化问题,可以使用 juc 引入的 BlockingQueue(阻塞队列),这种数据结构能在下面两种情况下阻塞当前线程
1 | public class ProducerConsumerTest { |
下面是使用 BlockingQueue 实现的生产者/消费者代码
注意要使用put/take这组方法,而不是offer/poll,因为后者会在满/空时直接返回(而非阻塞等待)。
1 | public class Producer implements Runnable { |
读写问题中有两类线程:
1 | public class ReaderWriterTest { |
上面的代码有个问题,就是写线程可能被饿死,因为第一个读线程通过rootEmpty.acquire
进来后,后续的读线程都不必再等待,可以直接进入临界区,而同时执行的写线程就永远都等在rootEmpty.acquire
上了。
改成如下的方式:
1 | public class ReaderWriterTest { |
我们先来看下最开始最直观的一种错误解法,这种解法会导致死锁:
1 | public class DiningPhilosophersTest { |
显然,n位哲学家刚开始都没有处于就餐状态,如果他们同时拿起左边的叉子,然后尝试取右边的叉子,就会直接导致死锁。
注意,上面发生死锁的必要条件是“n位哲学家同时就餐”,如果n位无法同时就餐,那这个问题也就迎刃而解了,所以我们额外引入一个footman
信号量,它的数量控制在n - 1
:
1 | private Semaphore footman = new Semaphore(count - 1); |
另外一种解决办法是让一个哲学家先获取右边的叉子再获取左边的叉子,这样其实解除了环路等待条件:假设有5个哲学家,其中4个哲学家拿到左手的叉子后,第五个哲学家会尝试取第一个叉子,也就是第一个哲学家左手的叉子,他们两个不满足死锁的条件。
这种思路的代码比较简单,就先忽略了。
这是一种相对比较极端的解,每个哲学家都需要等两边的人不在就餐的情况下才能就餐,否则他什么都不做:
1 | private static final int count = 2; |
这种解存在的主要问题是会发生饥饿,比如2个哲学家的情况下,可能1号会一直处于就餐状态,2号一直处于循环检测的状态,于是就发生了饥饿。
1 | #define N 10 //最多10个顾客 |
1 | #include <stdio.h> |
一个火车站有多个窗口,它们同时卖票,而票数使用一个 ticket 变量进行计算,对票数有查询和修改两个操作,这两个操作不能同时进行,并且写操作可能不是原子的,两个写操作也不能同时进行
在 Java 体系中,提到并发就不得不提到 JMM,因为所有并发安全都是围绕内存来展开的,可以说不懂内存结构就不懂并发。
在实际回收垃圾对象前,我们必须标识出哪些对象该被回收,即垃圾检测。
Object obj = new Object()
的 obj 就是一个强引用。OutOfMemoryError
错误,使程序异常终止,也不会回收强引用对象来释放内存,除非已经没有引用关联这些对象了。java.lang.ref
包中。Reference 抽象类是除强引用外的所有引用类型的父类,有以下几种子类
1 | MyObject obj = new MyObject(); |
堆中的每一个对象的对象域包含一个引用计数器。该计数器的维护规则如下:
但是一般垃圾回收器并不会采用这种算法,主要是因为引用计数算法存在循环引用的问题(注意不是栈帧里的引用,而是堆中实例的互相引用)
1 | public class ReferenceCountingGC { |
如上边代码所示,执行objA = null
和objB = null
后,它们二者的 instance 域仍然互相是对方的引用。
若一个对象没有引用链与任一个 GC Roots 相连时,此对象可回收
包括虚拟机栈中引用的对象、方法区中类的静态成员变量引用的对象、方法区中的常量引用的对象、本地方法栈中 Native 方法引用的对象
根部(Roots):表示引用链的头部
引用链(Reference Chain):多个引用形成的一条链
引用:是 reference 类型的对象,其中存储的数据代表的是另外一块内存的起始位置,有强引用(Strong)、软引用(Soft)、弱引用(Weak)、虚引用(Phantom)四种。
此算法的基本思想就是选取一系列 GC Roots 对象作为起点,开始向下遍历搜索其他相关的对象,搜索所走过的路径成为引用链,遍历完成后,如果一个对象到 GCRoots 对象没有任何引用链,则证明此对象是不可用的,可以被当做垃圾进行回收。
那么问题又来了,如何选取 GCRoots 对象呢?在 Java 语言中,可以作为 GCRoots 的对象包括下面几种:
如上图所示,Obj8、Obj9、Obj10 都没有到 GC Root 的引用链,因此它们会被标记为垃圾,即便 Obj9 和 Obj10 之间有引用关系。
对于可达性分析算法而言,未到达的对象并非是“非死不可”的,若要宣判一个对象死亡,至少需要经历两次标记阶段。
1 | /* |
先标记所有需要清除的对象,再统一回收。是最基础的垃圾回收算法,后续的收集算法都是基于这种思路并对其缺点进行改进而得到的。
问题
首先标记出所有需要回收的对象,使用可达性分析算法判断一个对象是否为可回收,在标记完成后统一回收所有被标记的对象。下图是算法具体的一次执行过程后的结果对比。
将可用内存划分为大小相等的两半,对每一块使用指针碰撞(从已分配内存向空闲内存空间移动对象大小的空间)的方法为对象分配空间,如果这一块内存用完,就将还存活的对象复制到另一半块上,将原来的这一半一次清理掉。
HotSpot 中使用的是 Eden-Survivor 方法,大体上每次使用一个 Eden 和一个 Survivor 来分配对象空间,当回收时,将这两块中还存活的对象一次性复制到另一块 Survivor 中,Eden 和 Survivor 的比例为8:1
。如果 Survivor 的空间不够了,就会使用老年代进行分配担保(Handle Promotion)。
优点:
像标记-清除算法清理后的内存空间并不规整,可能会有很多碎片,因此只能使用空闲列表(Free List)的方式分配内存。
缺点:
将内存分为两等块,每次使用其中一块。当这一块内存用完后,就将还存活的对象复制到另外一个块上面,然后再把已经使用过的内存空间一次清理掉。图是算法具体的一次执行过程后的结果对比。
标记过程和Mark-Sweep一样,但是不直接清除,而是让存活的对象向前移,再清理端边界外的内存。
标记过程还是和标记-清除算法一样,之后让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存,标记 - 整理算法示意图如下
标记-整理算法往往与标记-清除同时使用,优先执行标记-清除,当内存空间碎片过多时,才运行标记-整理压缩内存空间。
将 Java 堆分为新生代和老生代,根据各个年代的特点采取最适当的收集算法。在新生代中死得快,就选用复制算法(要复制的少),老生代中对象存活率高,就使用标记整理或标记清除算法。
GC文章有些常用的概念:
对于虚拟机中线程私有的区域,如程序计数器、虚拟机栈、本地方法栈都不需要进行垃圾回收,因为它们是自动进行的,随着线程的消亡而消亡,不需要我们去回收,比如栈的栈帧结构,当进入一个方法时,就会产生一个栈帧,栈帧大小也可以借助类信息确定,然后栈帧入栈,执行方法体,退出方法时,栈帧出栈,于是其所占据的内存空间也就被自动回收了。
而对于虚拟机中线程共享的区域,则需要进行垃圾回收,如堆和方法区,线程都会在这两个区域产生自身的数据,占据一定的内存大小,并且这些数据又可能会存在相互关联的关系,所以,这部分的区域不像线程私有的区域那样可以简单自动的进行垃圾回收,此部分区域的垃圾回收非常复杂,而垃圾回收也主要是针对这部分区域。
对于可达性分析而言,我们知道,首先需要选取 GCRoots 结点,而 GCRoots 结点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的局部变量表)中。方法区可以很大,这对于寻找 GCRoots 结点来说会非常耗时。当选取了 GCRoots 结点之后,进行可达性分析时必须要保证一致性,即在进行分析的过程中整个执行系统看起来就好像被冻结在某个时间点上,不可以在分析的时候,对象的关系还在动态变化,这样的话分析的准确性就得不到保证,所以可达性分析是时间非常敏感的。
为了保证分析结果的准确性,就会导致GC 进行时必须停顿所有 Java 执行线程(Stop the world),为了尽可能的减少 Stop the world 的时间,Java 虚拟机使用了一组称为OopMap的数据结构,该数据结构用于存放对象引用的地址,这样,进行可达性分析的时候就可以直接访问 OopMap 就可以获得对象的引用,从而加快分析过程,减少 Stop the world 时间。
OopMap 数据结构有利于进行 GC,是不是虚拟机无论何时想要进行 GC 都可以进行 GC,即无论虚拟机在执行什么指令都可以进行 GC?答案是否定的,因为要想让虚拟机无论在执行什么指令的时候都可以进行 GC 的话,需要为每条指令都生成 OopMap,显然,这样太浪费空间了。为了节约宝贵的空间,虚拟机只在”特定的位置“存放了 OopMap 数据结构,这个特定的位置我们称之为安全点。程序执行时并非在所有地方都能够停顿下来开始 GC(可达性分析),只有到达安全点的时候才能暂停。安全点可以由方法调用、循环跳转、异常跳转等指令产生,因为这些指令会让程序长时间执行。
现在我们已经知道了安全点的概念,即进行 GC 必须要到达安全点,那么在发生 GC 时如何让所有线程到达安全点再暂停呢?有两种方法:
现在问题又来了,当程序不执行的时候,如何让所有线程达到安全点呢?典型的就是线程处于 Sleep 状态或者 Blocked 状态,这时候线程是无法跑到安全点再中断自己的,虚拟机也肯定不可能等待该线程被唤醒并重新分配 CPU 时间后,跑到安全点再暂停。为了解决这个问题,引入安全区域的概念。安全区域是对安全点的扩展,可以看成由很多安全点组成,安全区域是指一段代码片段之中,引用关系不会发生变化。在这个区域的任何地方开始 GC 都是安全的。当线程执行到安全区域的代码时,首先标示自己已经进入了安全区域,那么,在这段时间里 JVM 发起 GC 时,就不用管标示自己为安全区域状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举(或者整个 GC 过程),若完成,线程继续执行;否则,它必须等待直到收到可以安全离开安全区域的信号。
根据作用区域的不同,GC 主要分为 3 种:
对象的晋升机制:
定义GC Cause的代码位置:src/share/vm/gc/shared/gcCause.hpp
和 src/share/vm/gc/shared/gcCause.cpp
:
1 | const char* GCCause::to_string(GCCause::Cause cause) { |
JVM会因这些Cause触发回收:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
列举一些经典的GC Cause及参考的解决方案:
-Xms
和-Xmx
的值设置得不一样,刚开始只会分配-Xms
大小的堆空间,每次不够时再向操作系统申请,这时必须进行一次GC。-Xms
和-Xmx
、-XX:-MaxNewSize
和-XX:NewSize
、-XX:MetaSpaceSize
和-XX:MaxMetaSpaceSize
这样的值设置成一样的。System.gc()
。System.gc()
一般用于清理DiectBuffer对象,因为DirectBuffer会申请堆外空间。System.gc()
的去留需要根据即使情况来判断。-XX:MaxMetaSpaceSize
这个属性限制,如果空间不够且无法继续扩容,则将触发OOM。发生过早晋升的根本原因可能是:Young/Eden区过小;分配速率过大。
晋升年龄受一个阈值MaxTenuringThreshold
控制,如果设置得过大,会导致该晋升的对象一直停留在年轻代,每次YoungGC都需要复制大量对象,失去了老年代的作用;如果设置得过小,大量对象被晋升到Old区,失去了年轻代的作用。不同情况下JVM内存成分不同,对象的生命周期分布也不同,因此晋升年龄是动态调整的。/src/hotspot/share/gc/shared/ageTable.cpp#compute_tenuring_threshold
可以看到 Hotspot 遍历所有对象时,从所有年龄为 0 的对象占用的空间开始累加,如果加上年龄等于 n 的所有对象的空间之后,使用 Survivor 区的条件值(Target-SurvivorRatio / 100,TargetSurvivorRatio 默认值为 50)进行判断,若大于这个值则结束循环,将 n 和 MaxTenuringThreshold 比较,若 n 小,则阈值为 n,若 n 大,则只能去设置最大阈值为 MaxTenuringThreshold。动态年龄触发后导致更多的对象进入了 Old 区,造成资源浪费。
如果是Young/Eden过小,可以调整比例,一般可以在Heap 内存不变的情况下适当增大 Young 区,一般情况下 Old 的大小应当为活跃对象的 2~3 倍左右,考虑到浮动垃圾问题最好在 3 倍左右,剩下的都可以分给 Young 区。
如果是分配速率过大,可以分析一下代码是不是哪些地方动态加载类过快了;或者直接扩大元空间,适应这种速度。
CMS FullGC频繁
CMS的原理是一次Young GC后,负责处理CMS的一个后台线程concurrentMarkSweep会不断地轮询,使用shouldConcurrentCollect()
检测是否达到回收条件。如果达到条件则调用collect_in_background()
启动一次Background模式GC。
判断是否进行回收的代码:/src/hotspot/share/gc/cms/concurrentMarkSweepGeneration.cpp
比较常见的有:-XX:+UseCMSInitiatingOccupancyFraction
触发、上次Young GC失败触发。
单次CMS GC(老年代GC)耗时过长
CMS回收主要耗时阶段是Init Mark和Final Remark,因为这两个阶段都需要STW,
见Old区垃圾回收细节:CMSCollector::collect_in_background
、CMSCollector::collect
-XX:+ DisableExplicitGC
来禁止 RMI 调用 System.gc。)-XX:MaxTenureThreshold
参数定义;-XX:PretenureSizeThreshold
参数定义;-XX:TargetSurvivorRatio
的时候,从这个年龄段往上年龄的对象进入老年代;在进行MinorGC之前,JVM的空间分配担保机制可能会触发3.2、3.3、3.4的发生,也就是触发一次FullGC。
所谓的空间分配担保机制,就是在MinorGC之前,虚拟机会检查老年代最大可用连续内存空间是否大于新生代所有对象的总空间。
最后,当发生 FullGC 之后空间还是不够,将抛出 OutOfMemoryError。
对象的内存分配,绝大部分都是在堆上分配,少数经过JIT编译后被拆散为标量类型并间接在栈上分配。
在堆上的分配又可以有如下分配,主要在新生代的 Eden 区分配,如果启动了本地线程分配缓冲,将按照线程优先在TLAB上分配,少数直接在 Tenured 区分配,虚拟机也提供了一些参数供我们来控制对象内存空间的分配。
总而言之,对象分配具有以下几种策略:
1 | -Xms20M -Xmx20M -Xmn10M |
新生代可用的空间:9M = 8M(Eden 空间容量) + 1M(一个 Survivor 空间的容量)
老年代可用的空间:10M
分配完 alloc1、alloc2、alloc3 之后,无法再分配 alloc4,会发生分配失败,则需要进行一次 Minor GC,survivor to 区域的容量为 1M,无法容纳总量为 6M 的三个对象,则会通过担保机制将 alloc1、allo2 转移到老年代,然后再将 alloc4 分配在 Eden 区。
大对象需要大块连续内存空间,大对象的出现容易提前触发 GC 以获取更大的连续空间来供分配大对象,可以设置-XX:PretenureSizeThreshold
的值来控制多大的对象直接分配到 Tenured 区,默认是 0,即所有对象不管多大都先在 Eden 区中分配空间。
1 | /** |
因为设置了-XX:PretenureSizeThreshold=3145728
控制大小超过 3M 的对象直接进入 Tenured 区,可以看到 5M 的对象直接被分配到了 Tenured 区。
每个对象有一个对象年龄计数器,与前面的对象的存储布局中的 GC 分代年龄对应。对象出生在 Eden 区、经过一次 Minor GC 后仍然存活,并能够被 Survivor 容纳,则设置年龄为 1,对象在 Survivor 区每次经过一次 Minor GC,年龄就加 1,当年龄达到阈值(默认 15),就晋升到老年代,虚拟机提供了-XX:MaxTenuringThreshold
来进行设置。
1 | /** |
如 GC 日志中所示,总共发生了两次 Minor GC:
因此,最后的结果是 alloc1、alloc2 在老年代,alloc3 在 Eden 区。
除了对象年龄自然达到-XX:MaxTenuringThreshold
而被转移到 Tenured 区外,如果在 Survivor 区中相同年龄所有对象大小的总和大于 Survivor 区的一半,则年龄大于等于该年龄的对象也可以直接转移到 Tenured 区、而无需等年龄达到-XX:MaxTenuringThreshold
。
1 | /** |
发生了两次 Minor GC:
最终,alloc1、alloc2、alloc3 在老年代,alloc4 在 Eden 区。
老年代连续空间大于新生代对象总大小、或者历次晋升的平均大小,就会执行 Minor GC,否则将进行 Full GC。GC 期间,如果 Survivor 区空闲空间小于存活对象,则需要老年代进行分配担保,把 Survivor 区无法容纳的对象直接转移到老年代。
例子在上一节中已经给出,这里不再赘述。
ConcurrentMarkSweepGeneration::compute_new_size()
1 | void ConcurrentMarkSweepGeneration::compute_new_size() { |
两个区域 A 和 B,初始对象在 A,继续存活的对象被转移到 B。
这两个区域并不需要根据 1:1 划分内存空间,而是将内存划分为一块较大的 Eden Space 和两块较小的 Survivor Space,在 HotSpot 中默认大小比例为 8:1。
当执行年轻代回收时会将 Eden 区存活的对象复制到一个空闲的 Survivor,下一次 GC 时将 Eden 区和这个 Survivor 区存活的对象复制到另一个 Survivor 区,因此总是会有一块 Survivor 区是空闲的。
当 Survivor 空间不够用的时候,需要依赖于老年代的空间担保。
一块区域,标记可达对象(可达性分析),然后回收不可达对象,这会引入碎片,因此在空间碎片过多导致无法继续分配时往往会执行一次整理来压缩空间。
相对标记清理算法来说多了碎片整理的过程,可以整理出更大的内存放更大的对象。
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。
根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存(有点 copy 的意思,但是比 copy 省空间。比清理好的一点是没有碎片)。
新生代:初始对象,生命周期短的
永久代:长时间存在的对象
整个 java 的垃圾回收是新生代和年老代的协作,这种叫做分代回收。
在大的分代回收的思想下面,不同的代区可以选择不同的收集器,而不同的收集器在不同的代区又会用到不同的算法。
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。
方法区的垃圾回收主要回收两部分内容:
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现。
NIO 类可以直接通过 Native 函数分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
使用堆外内存时需要注意:
由 DirectMemory 导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果我们发现 OOM 之后 Dump 文件很小,而程序中有直接或间接使用了 NIO ,那就可以考虑检查一下是不是这方面的原因。
垃圾收集器是内存回收算法的具体实现,随着 JDK 的升级我们已经有很多种垃圾收集器可供选择:
1 | jmap -heap <PID> |
Serial(串行)收集器是最基本、发展历史最悠久的串行收集器,JDK 1.5 之前默认都是此收集器,因为那时候 CPU 都是单核的。
简单而高效(与其他收集器的单线程相比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得更高的单线程收集效率。
ParNew 收集器就是 Serial 收集器的多线程版本(即并发模式),除了使用多线程进行垃圾收集外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与 Serial 收集器完全相同,两者共用了相当多的代码。
吞吐量(Throughput),即 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即“吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)”。
假设虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。
Parallel Scavenge 收集器无法与 CMS 收集器配合使用。
CMS 收集器运行过程中各步骤所涉及的并发和所需的停顿时间如下图所示:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。
顾名思义,CMS 采用标记清除算法,它的工作流程分为以下 6 个步骤:
下面以一个真实环境中的FullGC日志为例:
1 | 2020-08-20T04:37:36.159+0800: 638682.623: [GC (CMS Initial Mark) [1 CMS-initial-mark: 1930043K(2097152K)] 2000027K(4793536K), 0.2664430 secs] [Times: user=0.11 sys=0.02, real=0.26 secs] |
上面的GC日志中:
1930043K
:当前老年代使用的容量;2097152K
:老年代可用的最大容量;2000027K
:整个堆目前使用的容量;4793536K
:整个堆的可用容量;0.2664430 secs
:这个阶段的持续时间;[Times: user=0.11 sys=0.02, real=0.26 secs]
:对应 user、system 和 real 的时间统计。6.513/6.529 secs
:这个阶段的持续时间与时钟时间;[Times: user=2.11 sys=0.40, real=6.53 secs]
:时间统计,但是因为是并发执行的,并不仅仅包含 GC 线程的工作。0.024/0.026 secs
:持续时间与时钟时间;Times: user=0.03 sys=0.01, real=0.03 secs
:时间统计。Final Remark
重新标记阶段,会发生STW,暂停所有用户线程,从 GC Root 开始重新扫描整个堆,标记存活的对象。这一阶段是为了修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录。这一阶段停顿时间一般比初始标记阶段稍长,但远比并发标记时间短。需要注意的是,虽然 CMS 只回收老年代的垃圾对象,但是这个阶段依然需要扫描新生代,因为很多 GC Root 都在新生代,而这些 GC Root 指向的对象又在老年代,这称为跨代引用。YG occupancy: 571811 K (2696384 K)
:年轻代当前占用量及容量;Rescan (parallel) , 0.0743374 secs
:Rescan 是当应用暂停的情况下完成对所有存活对象的标记,这个阶段是并行处理的;weak refs processing, 0.0004330 secs
:第 1 个子阶段,处理弱引用;class unloading, 3.9423498 secs
:第 2 个子阶段,卸载不再使用的 class;scrub symbol table, 0.5589452 secs ... scrub string table, 0.0015701 secs
:最后一个子阶段,清理符号表和字符表。1 CMS-remark: 1930043K(2097152K)
:这一阶段之后老年代的使用量与总量;2501855K(4793536K)
:这一阶段后堆的使用量与总量(包括年轻代);4.5824373 secs
:这一阶段的持续时间,也就是 STW 的时间。[Times: user=0.47 sys=0.04, real=4.58 secs]
:这一阶段统计的持续时间。普通串行标记清除算法与并行标记清除算法(CMS)的比较如下图所示:
如上图可知,并发标记清除算法与串行标记清除算法之间的区别主要在于,前者将标记过程分成了 3 个部分,其中占用时间最长的Concurrent Mark
不需要stw
。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。
并发收集、低停顿,因此 CMS 收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。
G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一。它是一款面向服务端应用的垃圾收集器。
G1 可以用于年轻代和老年代,且算法分 3 个步骤,所以配置种类比较多。
只作用于年轻代的配置:
作用于老年代的配置:
其他配置:
在 G1 算法中,采用了另外一种完全不同的方式组织堆内存,堆内存被划分为多个大小相等的内存块,称为Region,每个 Region 是逻辑连续的一段内存,结构如下:
由上图可见:
堆内存中一个 Region 的大小可以通过 -XX:G1HeapRegionSize
参数指定,大小区间只能是 1M、2M、4M、8M、16M 和 32M,总之是 2 的幂次方,如果 G1HeapRegionSize
为默认值,则在堆初始化时计算 Region 的实践大小。
G1 可以独立管理整个堆空间,但是能够采用不同方式来处理新创建对象和已经存活了一段时间、经历过多次 GC 的老对象,以获取更好的收集效果。G1 中提供了三种模式垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发。
发生在年轻代的 GC 算法,一般对象(除了巨型对象)都是在 Eden Region 中分配内存,当所有 Eden Region 被耗尽无法申请内存时,就会触发一次 Young GC,这种触发机制和之前的 Young GC 差不多,执行完一次 Young GC,活跃对象会被拷贝到 Survivor Region 或者晋升到 Old Region 中,空闲的 Region 会被放入空闲列表中,等待下次被使用。
当越来越多的对象晋升到老年代 Old Region 时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 old gc,除了回收整个 Young Region,还会回收一部分的 Old Region,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old Region 进行收集,从而可以对垃圾回收的耗时时间进行控制。
Mixed GC 的执行过程有点类似 CMS,主要分为以下几个步骤:
如果对象内存分配速度过快,Mixed GC 来不及回收,导致老年代被填满,就会触发一次 Full GC,G1 的 Full GC 算法就是单线程执行的 Serial Old GC,使用标记-整理算法,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 Full GC。
G1 使用多个 CPU 来缩短 Stop The World 停顿时间,与用户线程并发执行。
G1 建立了可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
收集器 | 串行、并行or并发 | 新生代/老年代 | 算法 | 目标 | 适用场景 |
---|---|---|---|---|---|
Serial | 串行 | 新生代 | 复制算法 | 响应速度优先 | 单 CPU 环境下的 Client 模式 |
Serial Old | 串行 | 老年代 | 标记-整理 | 响应速度优先 | 单 CPU 环境下的 Client 模式、CMS 的后备预案 |
ParNew | 并行 | 新生代 | 复制算法 | 响应速度优先 | 多 CPU 环境时在 Server 模式下与 CMS 配合 |
Parallel Scavenge | 并行 | 新生代 | 复制算法 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
Parallel Old | 并行 | 老年代 | 标记-整理 | 吞吐量优先 | 在后台运算而不需要太多交互的任务 |
CMS | 并发 | 老年代 | 标记-清除 | 响应速度优先 | 集中在互联网站或 B/S 系统服务端上的 Java 应用 |
G1 | 并发 | both | 标记-整理+复制算法 | 响应速度优先 | 面向服务端应用,将来替换 CMS |
GC问题可能会有很多表象,比如:GC耗时增大、线程Block增多、慢查询增多、CPU负载高等。
为了排查根因,有几种比较有效的判断方法:
1 | int main() { |
1 | class A { |
1 | class A { |
强引用比较简单,虚引用很少见,容易混淆的是弱引用和软引用:
JVM 在分配空间时,若果 Heap 空间不足,就会进行相应的 GC,但是这次 GC 并不会收集软引用关联的对象,但是在 JVM 发现就算进行了一次回收后还是不足(Allocation Failure),JVM 会尝试第二次 GC,回收软引用关联的对象。
这个问题等价于为什么在不同的代中使用不同的垃圾收集器。
主要原因来自新生代和老年代的区别,新生代新陈代谢快,采用复制算法,Survivor 区可以相对较小,不会有太大的空间浪费,并且保证了较高的效率;老年代反之。
效率低,标记和清除都需要一次线性扫描,相当于比别的算法慢一倍,而且产生大量内存碎片,内存碎片的问题也出现在 C 语言的 malloc/free 中。
并行指各垃圾收集器线程可以同时运行,此时用户线程仍然处于等待状态。
并发指用户线程可以和垃圾收集器同时(可能是交替)运行,它们不在同一个CPU上执行。
从 3 次标记过程的特征可以看出,CMS 将耗时长的部分并行化了,从而保证整个 gc 过程的高性能。
Spring Cloud 是一系列框架的有序集合,它利用 Spring Boot 的开发便利性简化了分布式系统的开发,比如服务发现、服务网关、服务路由、链路追踪等。Spring Cloud 并不重复造轮子,而是将市面上开发得比较好的模块集成进去,进行封装,从而减少了各模块的开发成本。换句话说:Spring Cloud 提供了构建分布式系统所需的“全家桶”。
Spring Cloud 常常被拿来和 Dubbo 比较,实际上 Dubbo 只实现了服务治理,接入 Dubbo 的服务能够实现自动上下线、能通过 Dubbo 协议(其实 Dubbo 还支持其他很多协议)互联,但是 Dubbo 并不提供网关、配置中心、链路追踪等一系列微服务架构常用的技术,需要单独引入。
很多注解本身只是提供了一个标识,要实现注解所表示的功能,必然还会有一个扫描器扫描这个注解,然后将必须的Bean注入到Spring容器内,而且很多时候会为被注解的对象生成一个动态代理,以实现日志记录、接口幂等、限流等功能。
要自己实现一个注解,关键是如何扫描及如何生成代理并注入到Spring容器这两个步骤,具体的实现可以参考MapperScannerConfigurer
,大体逻辑是:
1 | @Service |
1 | @Service |
1 | @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) |
其中,循环依赖可能发生在第一步和第二步,其中第一步是因为构造方法中可能会需要传入其他 Bean。
1 | public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry { |
org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#getSingleton(java.lang.String)
总结Linux中的5种IO模型,其中最常用的是IO多路复用,特别是epoll是各种网络框架的底层IO框架。
1 | ls -F filename # 列出目录,使用*等符号标志文件类型,*表示可执行文件,/为目录文件 |
1 | mount | column -t # 查看挂载分区信息 |
制作目录硬链接。
1 | # 假设当前目录下有a和b两个目录 |
1 | fdisk -l # 查看硬盘分区信息 |
线程、进程,线程安全,进程同步,可见性,一致性,锁,信号量,并发,并行
从操作系统概念上说,线程是最小的可执行单位,也就是系统调度的最小单位。进程是资源分配的最小单位。线程是依赖进程存在的,共享进程内的资源,如内存,cpu,io 等。在操作系统的发展过程中,为了提高系统的稳定性、吞吐量和安全性,操作系统内核和用户态做了隔离,例如 Linux 有内核线程,用户线程,内核进程,用户进程,从根本上 Linux 是没有线程的,线程对 Linux 系统来说是个特殊的进程。那么用户线程和内核线程是一一对应呢?从宏观上看是一一对应的,在用户态的每一个线程,都会在内核有对应的执行线程,但是由于资源的限制,用户态的线程和内核线程是多对一的关系。用户进程和内核进程也类似。具体怎样对应的,这里就不探讨了。
为了提高操作系统的实时性,操作系统是以时间片轮转来实现任务调度的。理论上时间片内是不可以被中断的,可认为是 cpu 最小的单位执行时间。现代操作系统为了提高用户体验,线程都是抢占式的,而中断一般在时间片用完的时候发生。线程、进程和 CPU 都是多对一的关系,所以存在进程线程切换的问题。
线程内部还是有自己内存空间的,所以有个概念叫线程内存模型。线程内部有自己私有的本地内存,故线程和线程之间的本地内存存在可见性问题。例如全局变量 A 在线程 1 修改后,线程 2 并不一定能拿到 A 的修改值,因为线程 1 会把全局变量 A 拷贝到本地内存,修改后并不会马上同步。在编译的时候,编译器为了优化,(例如利用超线程技术)可能会重排指令的执行顺序,这就会存在一致性了。
在线程安全里面经常要讨论的两个问题就是:可见性和一致性。锁是什么东西呢?锁就是一道内存屏障,保证可见性和一致性的一种策略,由操作系统甚至更底层的硬件提供。加锁是消耗资源的,特别是在多核 CPU 上,现在多核 CPU 一般有 3 级缓存,一级缓存通常是单核独占的,而线程的本地内存很可能就保存在 cpu 的缓存里面,然而加锁就意味着保证可见性和一致性,需要中断同步数据,保证别人拿到的是最新修改值。由于用途不同,锁被设计成各种各样的,如互斥锁,读写锁,自旋锁,同步块,数据库的事务等,如果只要保证可见性的,可以不使用锁,在 java 里面可以使用 volatile 修饰全局变量。虽然在 c/c++,都有同样的修饰符,但是是不是一样的意思呢,请参考其他文章。
多个进程竞争资源造成的互相等待情况。
可重用性资源:可供重复使用多次的资源
不可抢占性资源:一旦系统把某资源分配给该进程后,就不能将它强行收回,只能在进程使用完后自动释放
可消耗资源:又叫临时性资源,它是在进程运行期间,由进程动态的创建和消耗的
互斥条件 一个资源每次只能被一个进程使用,即在一段时间内某资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
请求与保持条件 进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
不可剥夺条件 进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
循环等待条件 若干进程间形成首尾相接循环等待资源的关系
这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
形象地说,就是有两个酒鬼,一个有开瓶器,一个有酒,这两种资源都只能被一个人占有(互斥),且用完之前不能被另一个人抢去(不可剥夺),他们互相等对方手上的资源(循环等待),但又不肯放开自己手上的资源(请求与保持),因此陷入了死锁。
系统对进程发出每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配。
书上给出了两种死锁避免策略
死锁预防是设法至少破坏产生死锁的四个必要条件之一,严格的防止死锁的出现。
应用程序调用内核 IO 函数的过程如下图所示:
处于 OS 的安全性等的考虑,进程无法直接操作 I/O 设备,必须通过系统调用来请求内核完成 I/O 动作,而内核会为每个 I/O 设备维护一个 Buffer。
在整个请求过程中,数据输入至 Buffer 需要时间,从 Buffer 复制数据到进程也需要时间,这个等待时间是限制 I/O 效率的罪魁祸首,根据等待方式的不同,I/O 动作可以分为以下五种模式:
1 | #include <stdio.h> |