(2.2.1 竞态条件)静态条件的两种场景类型:“先检查后执行(Check-Then-Act)”和“读取-修改-写入”,例如对于线程安全的Vector,当执行如下代码时:
1
2if(!vector.contains(element))
vector.add(element);就存在“先检查后执行”的竞态条件。
(2.4 用锁来保护状态)一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。换一个方式解读就是:如果存在的可变状态多于一个,并且要保证这些可变状态的修改是原子的,那么约定做法就是封装在一个类中,并且对该类进行操作时,都要先获取该类对象的内置锁。
(2.5 活跃性与性能)如果只有一个变量需要原子操作,那么使用Atomic*是很有用的,但是如果多个变量需要原子性操作,那么就比较适合使用同步代码块,并且取消Atomic*的使用。
(3.1.3 加锁与可见性)这一节有一个重要的结论:
加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。
换句话说,加锁有两层用途:互斥排他和内存可见性。而后者是大家很容易忽视的。那内存可见性的一个应用场景就是:某一个线程写,其他线程读,这种情况下不存在并发竞争,而值存在内存可见性的问题。而这种情况下,大部分都是使用volatile,这也是为什么加锁的内存可见性的用途被忽视的另外一个原因。
(3.2 发布与逸出)构造函数中this逸出的三种场景:
- 在构造函数中new了内部匿名类,那么匿名类中就包含this指针
- 在构造函数中启用了新的线程,并且构造后直接启动
- 在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法)
(3.3.1 Ad-hoc线程封闭)只要你能确保只有单个线程对共享的volatile变量执行写入操作,那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作。在这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且volatile变量的可见性保证还确保了其他线程能看到最新的值。
(3.5.1 不正确的发布:正确的对象被破坏)本节中的
AssertionError
异常的例子的简单解释:1、JMM规范中并没有要求对象字段的初始化要happens before与另一个线程对该对象的可见性;2、if(n != n)
并非一个原子性操作,而需要3步操作,因此会出现第一次读取的n值和第二次读取的n值不相同的情况。而如果将对象的字段改成volatile后就能避免AssertionError
,这是因为JMM有规范要求:volatile sore要可见于其他线程。关于本节例子的两个很好的补充解释:知乎、StackOverflow由
if(n != n)
这行代码可以联想到一种并发编程易出错场景:某个函数里引用一个会被并发修改的字段,那么可能会出现在函数逻辑过程中发生变化的情况。这就引入了4.1.2 依赖状态的操作
的内容,已经并发编程的一个基本思想:但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中要一直等到先验条件为真,然后再执行该操作(死循环加CAS?)。(4.3.3 当委托失效时)虽然AtomicInteger是线程安全的,但经过组合得到的类却不是。也就是说,如果一个类中的字段即使都是线程安全的,但是这个类也不一定是线程安全的。当这些字段之间存在某些不变性条件时,就会导致“先检查后执行”操作的出现。而这是前面所讲的竞态条件的一种常见场景。
(4.3.5 示例:发布状态的车辆追踪器)注意本节的批注[1],很重要但是很容易忽视的一个并发编程错误场景!
(5.1.3 隐藏迭代器)Synchronized*容器的
toString
、hashCode
、equals
函数会调用迭代器,因此在调用这些方法时,记得加锁,例如记录日志时或者将这些容器作为另一个容器的key或value时。这都是很容易忽视的!否则在迭代的过程中如果其他线程执行了并发增删操作,很有可能抛出ConcurrentModificationException
。另外Synchronized*容器没有putIfAbsent
之类的函数,如果要实现类似的检查并执行的操作,需要自己加锁!或者使用专用的并发容器,例如:ConcurrentMap(5.4 阻塞方法与中断方法)当捕获到
InterruptedException
时,不要私自吞掉该异常,要么继续向上传递该异常或者干脆不不捕获该异常,要么重新恢复中断:Thread.currentThread().interrupt();
(5.5 同步工具类)闭锁(CountDownLatch):属于消耗性的只减不增的递减计数器,计时器为零时解锁;FutureTask:属于特殊的二元闭锁,运行中时阻塞,运行结束时解锁;信号量(Semaphore):属于池化的计数器,可赠可减,当池中计数器为0时阻塞,可以用任何容器加Semphore组合为有界容器。栅栏(CyclicBarrier):可以复用的递增计数器,初始化时,计数器为0,计数器未满时阻塞,计时器满时解锁,并且重置计数器。Exchanger:特殊的两方栅栏,任何一方都先发起交换请求,等待另一方响应后,交换达成,适用于读写线程进行无GC的缓冲交换(示例:baeldung)。
(7.1.1 中断)对中断的正确理解是:它并不会真正地中断一个正在运行的线程,而是发出中断请求,然后由线程(自己决定)在下一个合适的时刻中断自己。通常,中断是实现取消的最合理方式。这句话的意思是,程序不需要自己顶一个类似volatile的cancelled的状态,并轮询检测该状态,已决定是否取消。如果采用中断的方式,那么可以调用:inerrupt(),如果线程处于阻塞中,会收到
InterruptedException
,如果处于非阻塞中,其interrupted状态会被标记。因此完整的程序框架类似于:1
2
3
4
5
6
7
8
9
10
11
12
13
14class MyThread extends Thread {
public void run() {
try {
while(!Thread.currentThread().isInterrupted()) {
//执行业务逻辑
}
}
catch(InterruptedException e) {
//线程退出
}
}
public void cancel() {interrupt();}
}(7.1.2 中断策略)这一节有时间再继续消化!
(7.1.5 通过Future来实现取消)
Future.get(long, TimeUnit)
会返回4种异常,需要对这4种异常进行进行处理,特别是InterruptedException
,不要私自吞掉!当一个Future已经完成后,再次调用cancel也不会有什么影响。(7.1.7 通过newTaskFor来封装非标准的取消)这一节主要讲了一件事:如果想在执行
Future.cancel
时执行额外的操作,那么:1、集成Callable接口,实现自己的Callable;2、继承ThreadPoolExecutor
,重写newTaskFor函数,如果发现不是自己实现的Callable,那么调用super,否则返回一个自己实现的FutureTask,这个FutureTask的cancel函数先执行自己的一些操作,然后再调用super。(7.2 停止基于线程的服务)一个重要的概念:只有线程的所有者有权关闭线程,而线程池是其工作线程的所有者。
(8.1.1 线程饥饿死锁)在单线程的Executor中,如果任务一将任务二提交到同一个Executor,那么会导致饥饿死锁!而在多线程的Executor中,如果这种现象比较多,也可能发生死锁。因此有一条规则:不要在同一个Executor中递归提交任务。当Executor A依赖Executor B时,A的有效线程数量(有可能)实际上隐式地依赖于B的线程数量。例如某逻辑线程池使用了包含10个连接的JDBC线程池。
(8.2 设置线程池的大小)对于计算密集型任务,线程池的大小为CPU数量 + 1时,能实现最优利用率。而对于IO密集型线程池大小的评估公式为:CPU数量 * CPU利用率(大于等于0且小于等于1)* ( 1 + IO等待时间 / 计算时间)
(12.3.5 无用代码的消除)在性能测试过程中,有一些技巧可以避免编译器对某些无用的代码进行消除,大致思路就是保证计算结果被使用,但是不要引入IO操作,而导致了性能偏差。下面的例子是一个很好的技巧:
1
2if(foo.x.hashCode() == System.nanoTime())
System.out.print(" ");这段代码绝大数情况下不会成功,即使成功,也只是输出一个空字符。
(14.2.2 过早唤醒)Object的内置对象锁存在一个条件队列,而唤醒这个条件队列的条件谓词可能不止一个,也就是说导致唤醒一个Object线程的条件不止是一个,因此当Object在wait后被唤醒时,仍然需要继续检测当时导致wait的同一个条件谓词,这也从另外一个角度说明了为什么要循环检测条件谓词(另外一个条件是:唤醒现象的发生)
(14.2.4 通知)每当在等待一个条件时,一定要确保在条件谓词变为真时通过某种方式发出通知。换句话说:wait和notify(notifyAll)一般都是在同一个函数内成对出现?另外由于同一个条件队列可以对应于多个条件谓词,因此尽量使用notifyAll而非notify(如果非得使用notify,需要满足两个条件:1、唯一条件谓词:条件谓词只有一个;2、单进单出:最多只能唤醒一个线程来执行,换句话说就是唤醒后,只有一个线程可以继续工作,剩下的线程会竞争失败而继续wait),否则会面临信号丢失的风险。
(14.2.5 示例:阀门类)这一节的例子中,generation字段可以理解为批次,用一个形象的比喻就是这个Gate前可以等待一批又一批的线程,在await函数里,当线程被notifyAll后,当且仅当门是关闭状态并且当前的批次还是自己等待时的批次,才会再次wait(如果门已经开启,或者已经换了批次,那就证明gate肯定是开过,那我必须过去!)。
(14.3 显示的Condition对象)与内置锁和条件队列一样,当使用显示的Lock和Condition时,也必须满足锁、条件谓词和条件变量之间的三元关系。在条件谓词中包含的变量必须有Lock来保护,并且在检查条件谓词以及调用await和signal时,必须持有Lock对象。
(15.3 原子变量类)原子标量类(AtomicInteg、AtomicLong、AtomicBoolean、AtomicReference)没有重新定义hashCode和equals方法,因此每一个实例都是不同的,因此也不适用于做散列容器的键值。
《深入理解Java虚拟机》(第三版)阅读笔记
(2.2 运行时数据区域)jvm内存划分为5个区域,简单说就是2个堆(堆和非堆(方法区的别称))、2个栈(虚拟机栈和本地方法栈)、1个程序计数器
(2.2.5 方法区)方法区和永久代的区别在于(JDK 8以前),前者是java虚拟机规范的定义,而永久代是HotSpot虚拟机下特有的实现,且属于方法区的一部分,这么设计的目的就是为了垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的 工作。
(2.3.1 对象的创建)本节讲到:由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成 这两条字节码指令,验证测试代码如下所示:
1
2
3
4
5
6
7
8
9package com.example.demo.test;
public class Test2 {
public static void main(String[] args) {
final Test2 test2 = new Test2();
System.out.println(test2);
}
}执行:
javac Test2.java
,然后执行:javap -v Test2.class
查看字节码文件,输出内容为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67Classfile /mnt/f/Develop/Project/demo/src/main/java/com/example/demo/test/Test2.class
Last modified Dec 14, 2020; size 436 bytes
MD5 checksum b6b5fa11a474d65891c6991ba0d7143a
Compiled from "Test2.java"
public class com.example.demo.test.Test2
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Class #16 // com/example/demo/test/Test2
#3 = Methodref #2.#15 // com/example/demo/test/Test2."<init>":()V
#4 = Fieldref #17.#18 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/Object;)V
#6 = Class #21 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 Test2.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Utf8 com/example/demo/test/Test2
#17 = Class #22 // java/lang/System
#18 = NameAndType #23:#24 // out:Ljava/io/PrintStream;
#19 = Class #25 // java/io/PrintStream
#20 = NameAndType #26:#27 // println:(Ljava/lang/Object;)V
#21 = Utf8 java/lang/Object
#22 = Utf8 java/lang/System
#23 = Utf8 out
#24 = Utf8 Ljava/io/PrintStream;
#25 = Utf8 java/io/PrintStream
#26 = Utf8 println
#27 = Utf8 (Ljava/lang/Object;)V
{
public com.example.demo.test.Test2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class com/example/demo/test/Test2
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_1
12: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
15: return
LineNumberTable:
line 6: 0
line 7: 8
line 8: 15
}
SourceFile: "Test2.java"上述输出中,第54行为源码第6行的
final Test2 test2 = new Test2();
,第56行就是文中说的invokespecial。(2.3.2 对象的内存布局)源码地址:jdk8
Mark Word(64 bits) 锁状态 unused:25 | hash:31 | unused:1 | age:4 | biased_lock:0 | lock:01 正常 thread:54 | epoch:2 | unused:1 | age:4 | biased_lock:1 | lock:01 偏向锁 ptr_to_lock_record:62 | lock:00 轻量级锁 ptr_to_heavyweight_monitor:62 | lock:10 重量级索 | lock:11 GC标记 当开启指针压缩(UseCompressdOops)时,正常状态和偏向锁状态下的两个unused字段会变成:cms_free,具体参考:stackoverflow
(2.3.3 对象的访问定位)句柄访问和直接指针访问的本质区别在于:前者是间接引用(相当于中间又增加了一层,从而带来了更好的灵活性),后者是直接引用。
(2.4.2 虚拟机栈和本地方法栈溢出)经测试64位Linux下Xss最小值为:228k,默认值为:1024K(64位Windows 10下,两个值分别为:108K和0)
1
2
3
4
5
6
7
8
9
10
11
12java -Xss128K com.example.demo.test.Test2
The stack size specified is too small, Specify at least 228k
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.
java -XX:+PrintFlagsFinal -version | grep ThreadStackSize
intx CompilerThreadStackSize = 0 {pd product}
intx ThreadStackSize = 1024 {pd product}
intx VMThreadStackSize = 1024 {pd product}
openjdk version "1.8.0_275"
OpenJDK Runtime Environment (build 1.8.0_275-8u275-b01-0ubuntu1~18.04-b01)
OpenJDK 64-Bit Server VM (build 25.275-b01, mixed mode)TIPS:
- 如果运行程序出现了OutOfMemoryError异常,发生的区域很小的几率发生在虚拟机栈(主要发生区域是堆),理由是:HotSpot虚拟机不支持栈的动态扩展,所以线程运行期间不会申请内存扩展,从而不会导致OOM,那么只会出现在创建线程申请内存时出现OOM,有3种措施会可以促进这种OOM:1、虚拟机栈空间尽量小;2、每个线程的栈内存尽量大(Xss);3、创建的线程足够多。更详细地说,如果是在32位Windows系统下,由于单个进程最大内存限制是2G,那么排除掉Xmx的堆最大容量、直接内存、jvm本身消耗内存以及程序计数器(可以忽略不计)内存,剩下的内存才是虚拟机栈和本地方法栈的可用内存,如果虚拟机栈内存足够小,而且Xss配置得又偏大,那么,如果创建非常多线程的情况下,是有可能出现创建线程时导致虚拟机栈OOM的。
- 如果允许程序出现了StackOverflowError异常,比较大的可能性是递归调用导致了栈溢出,比较小的可能性是因为调用堆栈过深导致栈溢出,因为对于后者来说,默认Xss的大小为1024k,是很难导致溢出的。
(2.4.3 方法区和运行时常量池溢出)自JDK 7起,原本存放在永久代的字符串常量池被移至Java堆之中
(2.4.4 本机直接内存溢出)OOM后,dump文件比较小,而且又使用的Direct Memory,那么就要考虑是否是直接内存OOM
(3.2.3 再谈引用)强引用:打死也不能回收;软引用(SoftReference):OOM前回收;弱引用(WeakReference):下次GC时回收;虚引用(PhantomReference):根本不算引用,因为无法获得引用对象,应用场景:为了能在这个对象被收集器回收时收到一个系统通知。
(3.3.1 分代收集理论)
- 垃圾分代收集的本质在于对象的年龄。朝生夕灭的小鲜肉放在一起管理(称之为年轻代),相亲无数(也就是躲过了多次垃圾回收)的大龄青年(称之为老年代)放在一起管理,这可真够现实的。
- 除了CMS收集器,其他都不存在只针对老年代的收集
(3.3.2 标记-清除算法(Mark-Sweep))原理:标记处房间内的垃圾,然后直接清理掉,缺点:房间还是很乱(内存碎片),因为只清理了垃圾,而没有整理房间
(3.3.3 标记-复制算法(Mark-Copy))多用于年轻代垃圾收集器,原理:
- 基础版:房间分成两部分,每次只用其中一部分,每次回收时,清理垃圾,并将物品搬到另一个房间,缺点:浪费空间。
- 进阶版:房间分成三部分,一大两小(8:1:1),每次只用一大和一小,每次回收时,清理垃圾,并将物品搬到另一个房间
(3.3.4 标记-整理算法(Mark-Compact))多用于老年代垃圾收集器,原理:在标记-清除算法的基础上,额外增加整理(更形象的说,是“压缩整理”,从标记-整理算法的英文名:Mark-Compact就能看出来)的功能。
TIPS:
- 对于CMS收集器,平时多数时间都采用(并发)标记-清除算法,这也应对CMS的全称:Concurrent Mark Sweep,当空间碎片过多时采用标记-整理算法。这也对应CMS算法的缺点:1、容易产生内存碎片;2、一旦由于碎片过多触发了标记整理算法,就会导致长时间的STW
(3.4.2 安全点)安全点停顿:几乎所有虚拟机不再采用抢先式中断(Preemptive Suspension)而是采用主动式中断(Voluntary Suspension),也即虚拟机负责设置标记位,各个线程负责轮询检查标记位,然后运行到安全点上(有点类似于游戏服务器的某个全局模块设置标记位,在线玩家心跳逻辑里轮询检测这个标记位)。
(3.4.4 记忆集与卡表)通俗地讲,从垃圾收集区域来看,Remembered Set指的是谁指向了我的集合(如果有人指向了我,那么这个“我”就不能当做垃圾被回收)!这是一个抽象的逻辑概念模型。从具体实现的角度来看,最原始的Remembered Set就是存储对方所有跨带引用对象的集合,但是这是非常低效的,因此可以把这个“我”的区域(也即垃圾收集区域)划分成若干子区域(每个区域称之为卡页:Card Page),然后用一个数组标记该区域是否含有跨代引用指针(这个数组就是卡表:Card Table),在GC时将Card Table中存在跨代引用的Card Page进行扫描,从而减少GC Roots的扫描的范围(这种分组的思想是不是很熟悉,想一下武汉市在医疗资源紧缺时采取的新冠核酸筛的混检模式,就是讲每10人混合成一个样本,筛查效率理论上可以提升到原来的10倍)。
(3.4.5 写屏障)这一节讲了两件事:
卡表的状态维护机制:写屏障,通俗讲就是虚拟机层面的AOP
当多线程写同一块内存时,如果这块内存被同一个缓存行的卡表对应,那么意味着这个位于同一个缓存行的卡表会被多个线程修改,导致性能降低,优化机制为:不要无条件修改卡表,而是加一个判断(额,这个优化真的非常非常初级啊)
(3.4.6 并发的可达性分析)
- 三色标记:白色:还未过安检;黑色:已经过安检,安全;灰色:安检中
- 两种条件同时满足时,会出现白色误判为垃圾:1、扫描期间某黑色新指向了白色对象;2、同时某灰色到该白色的直接或间接引用也恰巧删除。这就导致了该白色对象仅被黑色对象引用,而黑色对象是安全的,不会再次扫描,这就导致错过扫描而被误判为垃圾。举一个形象的例子:三个包过机场的安检仪:黑包–灰包–白包这3个包用绳子相继连着,此时黑包已过安检,灰包安检中,白包等待安检,突然风雨突变,灰包和白包的绳子莫名断掉,同时有一股神秘力量将黑包和小白连起来(来自用户线程),但是这个连线却没穿过安检仪,那这种情况下白包就会错过安检(这个例子真是绝,我真是大聪明!)
- 消除白色误判的两种策略:1、破除上述条件一,也即增量更新(Incremental Update):如果黑色在扫描期间指向了白色,那就让它变灰;2、破除上述条件二,也即原始快照(Snapshot At The Beginning, SATB):先拍个照,当进行扫描时,不管灰色是否删除了白色的指向,都按照这个快照扫描。CMS是基于增量更新,G1、Shenandoah则是用原始快照。
(3.5.6 CMS收集器)
收集过程分成4个阶段:
- 初始标记(CMS initial mark):标记GC Roots能直接关联到的对象,会引发短暂的STW
- 并发标记(CMS concurrent mark):也即三色标记阶段,会导致某些白色被误判垃圾,也会导致漏过某些白色而产生浮动垃圾
- 重新标记(CMS remark):采用增量更新策略,重新标记,会引发STW
- 并发清除(CMS concurrent sweep):这也是CMS名称的由来,并发清除(concurrent sweep)
缺点:
- 并发标记对CPU敏感(默认GC线程数=处理器核心数量 +3)/ 4):CPU核心数大于等于4个时,并发标记线程数不超过25%,当并发线程数小于4个时,有一半运算能力执行GC,因此CPU敏感。
- 由于GC过程中浮动垃圾无法收集(要留到下一次),因此需要为老年代预留出一部分空闲空间(-XX:CMSInitiatingOccupancyFraction,JDK 6以后该默认值从JDK 5的68%提升至92%),防止并发失败(Concurrent Mode Failure)导致临时启用Serial Old。
- CMS正如它的名字所示,属于标记清除算法,因此在GC结束后会导致大量内存碎片。当申请大对象内存时,可能会出现内存碎片过多无法分配,导致提前触发Full GC(有两个参数:-XX:+UseCMSCompactAtFullCollection和-XX:CMSFullGCsBeforeCompaction可以相对改善,具体细节可以使用时查看相关资料)。
TIPS:
- 所有收集器中只有CMS有针对老年代的Old GC
(3.5.7 Garbage First收集器)
JDK 7 Update 4时,G1的”Experimental“的标识被移除,到JDK 8 Update 40,提供类卸载的支持,变成了Oracle官方称为“全功能的垃圾收集 器”(Fully-Featured Garbage Collector)
G1的Remembered Set结构:由于存在多个Region,而每个Region都要维护的Remembered Set都需要存储谁引用了我,因此Remembered本质上是哈希表,key为其他Region的起始地址,value为一个集合,存储的是该Region中卡表的索引号。这存在两个问题:1、Remembered Set是一个双向索引,这维护起来就麻烦了;2、Remembered Set比较多,因此要消耗额外的堆内存,大约在10%~20%之间。
Region中有一部分内存用于GC并发回收中的对象分配,这个区域都位于TAMS(Top at Mark Start)指针之上。G1默认TAMS地址以上的对象都是存活的,不进行GC。
G 1停顿预测模型一句话总结:每个Region进行回收统计,包括:回收耗时、Remember Set中脏卡数量等,G1根据每个Region的统计状态的新旧评价回收价值,并预测哪些Region集合可以在-XX:MaxGCPauseMillis内回收时获得最高收益。
收集过程同样(相对于CMS)分成4个阶段:
- 初始标记(Initial Marking):标记GC Roots能直接关联到的对象,还要额外(相对于CMS)修改TAMS指针的值,会引发短暂的STW
- 并发标记(Concurrent Marking):也即三色标记阶段,还要额外重新处理SATB记录上并发标记期间引用改动的对象。
- 最终标记(Final Marking):短暂的STW,处理上一步中遗留的少量的SATB记录。
- 筛选回收(Live Data Counting and Evacuation):1、更新Region的统计数据,并根据统计价值和成本进行排序;2、筛选Region回收集合;3、把回收Region集合中的存活对象移动到空的Region中,然后清空所有回收集合Region全部空间,由于需要移动存活对象,因此会引发STW,但是会多线程并行完成。
从G1开始,垃圾收集器的设计导向发生变化:追求全堆清理转向追赶内存分配速率(Allocation Rate)。我个人把这个新原则称之为遇熊逃命原则,也即不追求跑得最快,只要求不是最慢即可,换到GC角度上,那就是只要应付得了内存分配的速率即可。只要我打扫垃圾的速度比你制造垃圾的速度足够快,足以。这个理念非常牛B啊,所以才说G1是收集器的一个里程碑。
TIPS:
按理来说,G1是全堆回收的,那为什么仍然存在年轻代和老年代?个人推测是因为仍然沿用了HotSopt垃圾分代框架,而书中也多次用了“扮演新生代”或“扮演老年代”的说法,这也可以证明这个推测。
书中没讲到的G1的思想:G1实际上采取的是分治算法的思想,将大问题分解成小问题,逐个击破。从整体上讲,它属于标记-整理算法(根据前文内容,整理算法是让所有存活对象移动到内存一端,如果按照这个标准,那G1整体上并非严格意义上的标记-整理算法),而从局部上讲,又属于标记复制算法(Region A移动到Region B)。
(3.6 低延迟垃圾收集器)
- Shenandoah和G1的3个不同之处:
- 支持并发整理算法,G1在第4个阶段进行筛选回收时,是多线程并行处理,但是不会并发。
- 默认不使用分代收集,不存在新生代和老年代的Region
- 将G1的Remembered Set改为连接矩阵(Connection Matrix):降低维护成本,也降低伪共享的发生概率。
- Shenandoah收集过程:
- 初始标记(Initial Marking),标记GC Roots能直接关联到的对象,会引发短暂的与堆大小无关的STW
- 并发标记(Concurrent Marking):与G1一样,并发且并行
- 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,引发短暂的STW
- 并发清理(Concurrent Cleanup):清理连一个存活对象都没有找到的Region
- 并发回收(Concurrent Evacuation):这是Shenandoah的核心改进,通过读屏障和Brooks Pointers转发指针实现并发回收。
- 初始引用更新(Initial Update Reference):确保上一个并发回收阶段的线程都已经完成对象移动任务,会引发STW。
- 最终引用更新(Final Update Reference):修正存在于GC Roots 中的引用,最后一次引发STW,停顿时间只与GC Roots数量有关。
- 并发清理(Concurrent Cleanup):清理Immediate Garbage Regions。
TIPS:
- 本节开头提了:内存占用(Footprint)、吞吐量(Throughput)、延迟(Latency)组成了三元悖论,意指三者只能得其二,类似的三元悖论还有很多,例如分布式系统中著名的CAP定理:一致性(Consistency)、可用性(Availability)、分区容忍性(Partition Tolerance)。详情可以参考:周大的另外一本开源书籍或IBM Cloud Learn Hub
- Shenandoah将Remembered Set改为连接矩阵,从数据管理的视角上来看,实际上是把数据分散处理调整为全局集中处理,这也符合数据驱动开发的思想。例如在游戏开发领域,双向游戏好友是比较常见的社交功能,相比于把好友数据存储到每个玩家身上,维护一系列全局的好友关系数据将是更好的选择。因为如果使用前一个方案,那么在分布式环境下,单就数据一致性的这个问题,就会要求架构底层需要很好的支持,功能逻辑的复杂性也会大大增加。
- Brooks Pointer通过CAS操作保证并发时对象访问的正确性,CAS真是并发编程一大神器啊!
- Shenandoah和G1的3个不同之处:
(3.6.2 ZGC收集器):
- Region分成3种:Small Region(2MB,存储<256KB对象)、Medium Region(32MB,存储>=256KB&&<4MB 对象)、Large Region(2MB的整数倍,最小为4MB,存储>=4MB对象)
- Shenandoah收集过程:
- 并发标记(Concurrent Mark):操作与G1、Shenandoah类似,也会引发STW,区别在于标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志 位。
- 并发预备重分配(Concurrent Prepare for Relocate):1、根据特定的查询条件统计得出 本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set);2、类卸载和弱引用处理(从JDK 12开始支持)。
- 并发重分配(Concurrent Relocate):复制Relocation Set中的存活对象到新Region,每个Region维护转发表(Forward Table),记录转发关系。并发期间,如果对象访问,由于染色指针的存在,ZGC知道该对象是否处于Reloaction Set,因此会被内存屏障截获,转发到新Region上,并同时更新引用(指针自愈)。
- 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所 有引用。实际上即使没有这一步,这些对象也可以在之后的访问时,通过指针自愈实现重映射。ZGC将此工作合并到下一次垃圾收集循环的并发标记阶段中,从而更加节省了一次对象图的遍历。
TIPS:
- 在上述第3个“并发重分配”过程,当复制Relocation Set中的存活对象到新Region时,周大没有详述实现细节,推测应该有类似的CAS操作来更新对象指针的染色指针,这是一个小遗憾。
- 根据上述第4个“并发重映射”过程的描述,其过程被合并到下一次GC中,那ZGC岂不是只有3个大的过程?这是一个不太大的疑惑点。
(3.8.5 空间分配担保)在JDK 6 Update 24以前,当老年代最大连续可用空间小于新生代所有对象总和时,两种情况下会触发Full GC:
- -XX:+HandlePromotionFailure允许担保失败,但是老年代最大可用的连续空间是否小于历次晋升到老年代对象容量的平均大小
- -XX:-HandlePromotionFailure不允许担保失败
TIPS:
在JDK 6 Update 24之后,HandlePromotionFailure参数已经被去掉,测试代码:
1
2
3
4
5☺ java -XX:+PrintFlagsFinal -XX:+HandlePromotionFailure -version
Unrecognized VM option 'HandlePromotionFailure'
Did you mean '(+/-)PromotionFailureALot'?
Error: Could not create the Java Virtual Machine.
Error: A fatal exception has occurred. Program will exit.上述规则调整为:只要老年代的连续空间大于等于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行Full GC,换言之只有这两个条件同时满足,蔡楚发Full GC。
(4.2.2 jstat:虚拟机统计信息监视工具):远程虚拟机进程的VMID为:[protocol:][//]lvmid[@hostname[:port]/servername]
(7.3.5 初始化)关于代码清单7-6中,字段B的值将会是2而不是1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}书中的解释并不是很彻底,可以使用javac编译,然后借助
javap -v
命令得到更清新的答案,其Parent的javap命令解析如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36javap -v StaticMethod\$Parent master ✗
Warning: Binary file StaticMethod$Parent contains com.example.demo.test.StaticMethod$Parent
Classfile /mnt/f/Develop/Project/demo/src/main/java/com/example/demo/test/StaticMethod$Parent.class
Last modified Dec 24, 2020; size 399 bytes
MD5 checksum 0f4fdfb9857a60ee769dbb6b4a2f3a0d
Compiled from "StaticMethod.java"
class com.example.demo.test.StaticMethod$Parent
minor version: 0
major version: 52
flags: ACC_SUPER
Constant pool:
#1 = Methodref #4.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #3.#15 // com/example/demo/test/StaticMethod$Parent.A:I
#3 = Class #17 // com/example/demo/test/StaticMethod$Parent
省略……
{
省略……
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: iconst_1
1: putstatic #2 // Field A:I
4: iconst_2
5: putstatic #2 // Field A:I
8: return
LineNumberTable:
line 6: 0
line 8: 4
line 9: 8
}
SourceFile: "StaticMethod.java"
InnerClasses:
static #18= #3 of #16; //Parent=class com/example/demo/test/StaticMethod$Parent of class com/example/demo/test/StaticMethod通过上述代码可以发现,Line 54-55应对与源码中的类变量的定义:
public static int A = 1;
,而Line 56-57应对与有静态语句块中的赋值:A = 2;
,也就是说:编译后的static静态语句块除了包含程序员的逻辑代码,还额外插入了编译器自动生成的对静态变量初始化的代码,且这些插入代码的位置符合如下原则:如果静态变量定义在static静态语句块上面,那么在程序员逻辑代码上面按照声明顺序插入,否则在程序员的逻辑代码下面按照声明顺序插入。当Parent类完成初始化后,成员变量A的值为2,当Sub类进行初始化时,要调用自己的<clinit>()
,按照文中所述:Java虚拟机会保证在子类的()方法执行前,父类的()方法已经执行 完毕。Sub进行初始化时A的值已经是2,因此赋值给B后就是2了。(7.4.2 双亲委派模型)
“双亲委派”这个术语实在是翻译得败笔,根据知乎上一个比较形象的回答,翻译为“啃老模式”更为形象。也就是说遇到类加载请求时,先让父加载器去加载,父加载器加载不了了自己才去加载。
(8.3.1 解析)静态方法、私有方法、实例构造器、父类以及final方法(由于历史设计的原因,final方法是使用invokevirtual指令来调用)在类加载的时候就可以把符号引 用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method)
(8.3.2 分派)
- 静态分派对应的是方法的重载(Overload),动态分配对应的是类的重写(Override)。
- 当子类声明了与父类同名的字段时,虽然在子类的内存中两 个字段都会存在,但是子类的字段会遮蔽父类的同名字段
(8.4.3 java.lang.invoke包)一句话解释Reflection和MethodHandle的区别:Reflection是重量级,而MethodHandle 是轻量级。
对于普通变量(相对于volatile变量):要求read/load、write/store成对、顺序出现,但是允许不连续出现(也即中间可以插入其他指令)。
对于volatile变量:
- 只有当前一个指令是load时,才允许执行use;且只有后一个动作是use时,才能执行load动作。换句话说:额外要求load和use是连续成对出现的,结合上面第2条,那就是use/load/read一条龙。这条规则要求在工作内存中,每次使用volatile变量前都必须先从主内存刷新最新的值,保证能看见其他线程对该变量的修改。
- 只有当前一个指令是assign时,才允许执行store;且只有后一个动作是store时才能执行assign动作。换句话说:额外要求assign和store是连续成对出现,结合上面第2条,那就是assign/store/write一条龙。这条规则要求在工作内存中,每次修改volatile变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对该变量所做的修改。
- 对于任意两条龙A和B,如果A的龙头(assign或use)发生在B的龙头之前,那么A的龙尾要在B的龙尾之前(我这个解释真是太牛了!)
关于互斥量(mutex)和信号量(semaphore)的区别:
- 正如他们的名字一样,前者的使用场景是保护资源的独占性,注重的是竞争关系;而后者的场景是等待“信号”的发生,更倾向于生产者/消费者模型中的消费者,注重的是供需关系。[知乎回答](semaphore和mutex的区别? - 人马座的回答 - 知乎 https://www.zhihu.com/question/47704079/answer/528324049)
- 对于mutex,只有上锁的线程才有资格解锁;而对于semaphore,它可以被任意的线程获取和释放。
简述java的synchronized关键字:
- 它是java中最基本的互斥同步的手段,属于一种块结构的同步语法。
- 被javac编译后,会在同步块的前后分别形成moniterenter和moniterexit两个字节码指令
- 这两个字节码都需要一个reference类型参数指明要锁定的和解锁的对象。如果没有指明,那么就根据synchronized修饰的方法类型决定是锁对象实例还是Class对象。
- 在执行monitorenter指令时,首先要去尝试获取对象的锁,并且锁计数器加一,执行 monitorexit指令时会将锁计数器的值减一。计数器清零时,锁被释放。并且可以推论出:对于同一个线程来说,synchronized是可重入的。
简述java的并发机制(或者同步机制)
- 同步的定义:同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用。
- 基于同步的定义或者目的,有两种实现思路:1、互斥同步,其基本思想是对于共享资源的获取是悲观的(也即悲观锁),因此在访问资源时,都进行加锁操作,例如java语言层面提供的synchronized关键字。2、非阻塞同步,其基本思想是对于共享资源的获取时保持乐观的。不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数 据的确被争用,产生了冲突,那再进行其他的补偿措施。
关于JDK 6引入的自适应自旋锁的机制
- 自旋时间不再固定,而是由同一把锁前一次的自旋时间以及锁拥有者的状态决定。
- 假如刚刚自旋等待获取过同一把锁,并且持有锁的线程正在运行中,虚拟机会认为很可能再次成功,就会增加自旋次数
- 假如很少成功获得过锁,那么可能直接省略掉自旋,避免浪费CPU资源。
SED命令实践
sed命令执行时包含了若干个循环(cycle),而每个循环又包含若干个命令(command),完整的执行流程图为(图片来源):
tips: 如果sed语法的第一个字符是“/”,那么表示这是一个正则表达式的地址定位方式。(细品)
sed使用场景示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43echo 'line 1\nline 2\nline 3' | sed 's/line/"&"/g' #将所有的line加上双引号
echo 'line\t1\nline\t2\nline\t3' | sed -n 'l' #显示文本中的控制字符
echo 'line 1\nline 2\nline 3' | sed -n '/1/=' #显示包含1的行的行号,输出:1
echo 'line 1\nline 1\nline 2\nline 3' | sed -n '$=' #显示总行数,输出:3
#删除某几行文字(可以延伸到保留某几行,或删除或保留匹配的行)
echo 'line 1\nline 2\nline 3' | sed '1d' #删除第1行
echo 'line 1\nline 2\nline 3' | sed -n '1!p' #功能同上
#sed默认会将pattern space中的数据输出到屏幕,如果使用FLAG p,还会每次额外把current pattern space输出到屏幕,举例
echo 'line 1\nline 2\nline 3' | sed '/.*/p' #匹配所有的字符,并且会把pattern space和current pattern space中的内容都输出到屏幕上,因此每一行文字都会重复输出两次
echo 'line 1\nline 2\nline 3' | sed -n '/1/p' #匹配包含“1”的行,但是-n选项会禁止pattern space输出到屏幕,而FLAG p又会把current pattern space中的内容都输出到屏幕上,因此屏幕上显示的是:line 1,也即实现了grep的功能。记住:option -n和command p是一家。
#在日常工作中,经常需要使用sed进行替换(例如部署服务器时,进行模板变量的替换),而需求是屏幕上只输出被修改的行,可以使用FLAG w将修改内容输出的stdout,如下所示:
☺ cat 1.txt
123
456
789
zhengtongshan@DESKTOP-R7DT0R0 /mnt/e/Tetris/src/1_Branches/rtm_tetris_zhugan
☺ sed -i 's/1/s/gw /dev/stdout' 1.txt
s23
zhengtongshan@DESKTOP-R7DT0R0 /mnt/e/Tetris/src/1_Branches/rtm_tetris_zhugan
☺ cat 1.txt
s23
456
789
#在日常工作中,会遇到将制定的两行合并为一行的情况,情景为:如果发现某行包含某个字符,则将下面一行和本行合并,如下面的例子,如果行的第一个字符为a,则将后面的一行合并过来
☺ echo 'a\nb\nc\na\nb' |sed -e '/^a/N' -e 's/\n//'
ab
c
ab
#下面两个语句是等效的:(细品)
man cp | sed 's/copy/{&}/w cp.txt' #将man page中的〝copy〞字串加上大括号〝{}〞,并将current pattern space写入“cp.txt”
man cp | sed -n 's/copy/{&}/p' > cp.txt
#多个FLAG可以叠加使用,但是和其他Linux指令一样,与文件有关的FLAG要放在最后
man cp | sed 's/copy/{&}/Igpw cp.txt' #不区分大小写(FLAG I)将每行 (FLAG g)的“copy”加上大括号(Command s),将pattern space输出到屏幕(FLAG p),并将current pattern space写入文件“cp.txt”(FLAG w)
echo 'line 1\n\nline 2\nline 3' | sed '/^$/d' #删除空白行
echo 'line 1\nline 2\nline 3\nline 4' | sed -e 'N' -e 'N' -e 's/\n/+/g' #用两次N连续读取两个下一行(此时pattern space里有三行),然后把换行符都换成+,执行完毕后,输出为:
line 1+line 2+line 3
line 4使用t label可以实现事务性的修改,比如两个替换要么同时进行,要么就都不做。
command n命令的流程是:1、如果没有配置地址或匹配则输出pattern space(如果不满足则跳过下一步);2、然后读取下一行到pattern space。详情参考:Sed 命令完全指南中的次行命令一节,例如:
1
echo 'line 1\nline 2\nline 3\nline 4\nline 5\nline 6' | sed -e 'n' -e 'n' -ne 'p' #当第1个n执行后,pattern space变成:line 2,当第2个n执行后,pattern space变成:line 3,然后执行p后,打印current pattern space:line 3,然后进入下一个循环,打印出:line 6,上述sed可以简写成:sed -ne 'n;n;p',此外我们发现p命令的n选项是一个全局选项,它对整个sed语句生效而不仅限于p命令自己
command N、D可以实现联合多行条件判断(例如:满足某一行有ID字符串,且下面两行分别包含NAME和ADDRESS字符串,则执行删除ID行的操作),实现更复杂的操作,如果不使用command N、D,那么sed的操作仅限于行内。
command D为删除current pattern space的第一个字符到换行,而command P类似,只是将删除变为打印。
command c和s的区别在于,前者是整行替换,后者还需要指定要替换的pattern。
Windows上搭建SOCKS代理
问题提出:
驻厂深圳后,办公地点的网络不再是专线,因此IP不再是固定的,而是由运营商动态分配,而线上某些业务都开启了IP白名单,如果仍然通过添加白名单的方式,那么将会非常麻烦。总结一下受限的业务场景,包括:
- ssh连接腾讯云服务器
- http访问蓝鲸运维平台
- Jenkins Slave(Windows 7)服务器拷贝服务器版本到腾讯云
方案思路:
考虑到ssh提供了socks代理,因此可以在北京公司部署一台固定IP且已经加入IP白名单的服务器(假设为HostA,IP为:10.12.28.114,OS为Windows 10),然后开启socks代理,受限业务的流量都通过socks代理。
方案实施:
- 安装OpenSSH(windows 10 version 1803及以后的版本已经集成),在HostA上:设置–>应用–>应用和功能–>可选功能–>添加功能–>OpenSSH服务器
- 开启sshd服务:在HostA上,控制面板–>系统和安全->管理工具–>服务–>OpenSSH SSH Server
- 开启socks代理:ssh -f -N -D 0.0.0.0:1080 win10@127.0.0.1,此命令会在1080端口上开启sockets代理(关于如何使用ssh-key登录,可以参考这篇文章:Configuring SSH Key-Based Authentication on Windows 10/ Server 2019,并着重关注: How to Login Windows Using SSH Key Under Local Admin? )
问题解决:
- 受限场景1:SSH连接工具上设置socks代理,如图所示:
- 受限场景2:到控制面板–>Internet选项–>连接–>局域网设置–>代理服务器–>高级 里设置socks代理,然后使用IE访问蓝鲸运维平台
- 受限场景3:由于jenkins slave机器是windows 7系统,本身没有集成OpenSSH Client组件,因此考虑在slave上安装ssh客户端,最终考虑使用git附带的Unix tools中的ssh功能(安装时需要把Unix tools加入环境变量),在
%USERPROFILE%/.ssh/config
内增加ProxyCommand命令行进行代理,命令如下:ProxyCommand connect -S 10.12.28.114:1080 %h %p
如果要是用http代理,需要把 -S 改为 -H ,回到jenkins配置界面,配置scp命令拷贝版本即可。
C#高级特性拾遗
Conditional(“条件”) 可以优雅地替代宏定义
泛型类中的static字段和非泛型类的字段有所区别,后者中该static字段属于该类,而前者中该static字段仅属于该泛型类,例如
Generic<A>.staticField
和Generic<B>.staticField
是不同的。对于这个特性,可以有比较进阶的用法,例如:1
2
3
4
5
6
7public class Foo {
public static int lastId;
}
public class Foo<T> : Foo {
public static int id = ++lastId;
}那么对于每一个
Foo<T>
的子类,启id都是唯一的,从而实现id自我管理(而这里的泛型T只是被借壳而已,没有任何用处)。使用
Type.MakeGenericType
,可以在运行时生成泛型类,这是一个java不具备的特性,举例:1
2
3var type = typeof(Dictionary<,>);
Type[] typeArgs = {typeof(string), typeof(int)};
var genericType = type.MakeGenericType(typeArgs)default关键字:在泛型类和泛型方法中产生的一个问题,给定参数化类型 T 的一个变量 t,只有当 T 为引用类型时,语句 t = null 才有效;只有当 T 为数值类型而不是结构时,语句 t = 0 才能正常使用。 解决方案是使用default 关键字,此关键字对于引用类型会返回 null,对于数值类型会返回零。
对于
ManualResetEvent
:可以通过构造函数初始化为有信号或无信号状态。
当且仅当
ManualResetEvent
处于无信号状态时(也即Set没有被调用或者初始化为无信号状态),其他线程调用了ManualResetEvent.WaitOne
才会被阻塞。调用
ManualResetEvent.Reset
可以阻塞所有其他调用了ManualResetEvent.WaitOne
的线程应用场景:工作A和B需要并发执行,但是需要A执行完毕后再执行B。最佳实践:a、A线程(称之为
ManualResetEvent
控制线程)调用ManualResetEvent.Reset
使之进入非信号状态;b、B线程(称之为被控制线程或等待线程?阻塞线程?)在适当位置调用ManualResetEvent.WaitOne
等待信号;c、A线程调用ManualResetEvent.Set通知B线程继续执行。可以通过实现一个public的无参的返回
IEnumerator
的GetEnumerator()
为一个类扩展foreach语法,例如(详情):1
2
3
4
5
6
7public IEnumerator<Tile> GetEnumerator() {
for(int x = 0; x < this.size.x; x++) {
for(int y = 0; y < this.size.y; y++) {
yield return this[x, y];
}
}
}