本内容是学习马士兵老师的B站公开课程的学习笔记,若有侵权,请联系删除
一 计算机基础
1 计算机结构
- 示意图
- CPU组件
- ALU 算术逻辑单元
- Register
寄存器,存储需要计算的数字 - PC 计算器
程序计算器,记录程序指令执行的当前位置 - 其它:
- CU MMU cache
- 程序的构成
- 程序 = 指令 + 数据
- IO Bridge 数据总线
- 从内存读入到CPU计算,要使用总线
- 分类:
- 控制线
读指令 - 地址线
读取地址信息 - 数据线
读取数值
- 控制线
2 程序、进程与线程
- 程序
- 一个程序可以在同一个机器跑两份,即两个进程;
- 进程
- 需求:同一个进程内部,有多个任务并发执行的需求(一边计算、一边收集网络数据、一边刷新页面)
- 计算机分时,实现多进程共同运行
- 使用多进程实现功能并发处理,但是每个进程是有自己独立的内存地址空间的,多进程相互同步和共享数据的危险比较大,进程A 很轻易影响搞死进程B;
- 线程
- 进程内部,有多个计算任务,共享进程的内存空间,但是不共享计算;方便了进程内部之间的数据同步和通信,并保持了进程与进程之前的独立和隔离。
- 进程是静态的概念,程序进入内存,分配对应的资源:内存空间。进程进入内存会产生一个主线程
- 线程是动态的概率,是可执行的计算单元。线程共享进程内部的内存资源。线程时指令,计算的数据在内存中放着。
- 线程切换(OS)
- 线程T1执行到哪条指令,数据状态,在切换到T2线程时,要保存T1线程的内容上下文、现场。下次切换回到T1后可以继续从原来的中止位置可以继续执行。 即线程切换时要保存上下文保存现场。
- 问题:
- 是不是线程数量越多、执行效率越高?
答:不是,比如一万个活的线程,要保存1万个上下文,切换更加频繁,会耗费过多的资源。 - 对于一个程序,设置多少个线程合适?
答:
- 计算设置合理线程数的理论计算公式
实际实践中,是通过压测来设置合理的值
3 (P7) CPU的并发控制:缓存一致性协议
3.1 CPU访问存储的速度
- ALU访问Register的速度是 ALU访问内存速度的100倍。即访问存储器的速度比是1:100;因为ALU距离Register很近。
- CPU缓存
- LRU LFU内存淘汰策略
- 为重复利用CPU的计算能能力,在CPU和内存之间添加缓存。缓存可以设置多层缓存
- 示意图
- 目前缓存设置有3级缓存;
- 目前,一般L1 L2缓存在CPU核内部。L3缓存是在一颗CPU的多个核之间共享。
- 使用方式
- ALU去Register读取数据,Register先去L1找,L1没有再去L2,L2没有再去L3,L3没有再去内存
- 超线程
即一个ALU对应多组(Register + PC) - 缓存行
即一次性读取的数据块,即1次缓存一行数据。依据:程序的局部性原理:空间局部性、时间局部性。
- 缓存行的大小:过大过小都不好
- 过大,读取速度慢,命中率高
- 过小,读取效率高,但是命中率低
- 工业界结论:一个缓存行 64Byte(2021),
- 缓存一致性协议
- 由于缓存行的存在,必须有一种机制来保证缓存数据的一致性,即缓存一致性协议。每个CPU厂商都有自己的协议实现,比如Intel 的 MESI
- CPU级别的并发控制
- 缓存一致性协议
- 关中断
- 系统屏障
- 总线锁/缓存锁
3.2 马老师的课程体系
3.3 程序的执行顺序
- CPU的流水线设计
- CPU的乱序执行
- 为了提高效率(在等待费时的指令的时候,可以去执行后面的指令)
- 乱序是存在的证明示意图
- 本地证明确实会出现乱序执行的场景。
- 理论: as-if-serial
- 单个线程,两条语句,未必时按顺序执行;
- 单个线程的重排序,必须保证最终一致性
- as-if-serial 看上去像序列化(单线程)
- 会产生的后果
- 多线程会产生不希望看到的结果
- 哪些指令可以互换顺序
- happens-before原则,
- 双检锁 单例模式-Double Chekc Lock 存在的问题
- 线程1创建懒汉模式创建对象;对象只创建了一半,还没有完成初始化完成,线程2判断实例非空,就会直接拿着该实例了一半的对象去使用,出现BUG。
- synchronized可以保证代码块内部的原子性和可见性,但是无法保证代码的有序性;
- 放置乱序执行的方式
-
禁止编译器乱序
- 使用内存屏障阻止指令乱序
-
内存屏障是特殊指令,看到这种指令,前面的必须执行完,后面的才能执行。intel:ifence sfence mfence(CPU 特有指令)
-
JVM中的内存屏障
- 所有实现JVM规范的虚拟机,必须实现四个屏障
- LoadLoadBarrier LoadStore SL SS
-
- volatile的底层实现
- volatile修饰的内存,不可重排序,对volatile修饰的变量读写访问,都不可以换顺序;
- hostspot实现源码:C++代码中
- JVM 的内存屏障
- Load Load
- Store Store
- Load Store
- Store Load
总结,这些只是进程中的操作,关键还是需要底层的OS和CPU的支持
- Volatile 可见性
- 保证可见性
- 禁止重排序
Volatile实现原理
- volatile的实现细节
- 上述只是JVM层面的实现,不同的JVM有不同的实现,hotspot的实现原理为:
- CPU级别的并发控制方法
- 关中断
- 缓存一致性协议
- 系统屏障
包括编译级别、指令级别 - 总线/缓存锁
lock 是总线或缓存锁
- 参考书
编码,隐匿在计算机背后的语言
4 (P14) synchronized&AQS
4.1 CAS
- CAS 基本示意图
compare and swap
compare and set
compare and exchange
- 作用
- 通过使用CAS来实现了多线程情况下不阻塞每个线程并
- 且实现了对数据的并发访问设置值;
- CAS:
- ABA 现象
- 线程1 将变量num 设置为A,又设值为B 有改成A,对于线程2,如何识别线程1 是否真正变化了? 添加版本号,即线程2不仅仅看值还看版本号; 或添加标志位flag;
- CAS的底层实现
- CAS 的底层实现和synchronized,volatile的底层实现都是一致的。
- 汇编源码实现
- lock cmpxchg 指令 (汇编指令)
- lock从硬件级别上保证了一个线程在修改值的时候,其他线程不能去修改;cmpxchg 自己独立不能保证原子性
- 硬件层面:lock指令在执行后面的指令的时候锁定一个北桥信号(不采用锁总线的方式)
- 对象在内存中的布局
- 起始的8Byte是 MarkWord
- 起始的8Byte是 MarkWord
- 问题
Object o = new Object();
问题一:O 在内存中占用多少个字节?
答:对象头Markword 占用8个字节,对象的指针一般占用8或者4个字节,如果JVM开启了指针压缩,则指针渣勇4个字节,由于一个对象占用的字节数必须要为8Byte的整数倍,所以会再补充4个字节,Padding。 所以一共占用16Byte。如果没有开启指针压缩,则指针也占用8字节,再加上Markword的8个字节,一共也是16个字节。 如果一个对象还有成员变量,则会在上述基础上再添加成员属性锁占用的字节数,然后再视情况是否要padding。 同时注意,一个对象添加方法是不占用字节的,只有对象的成员变量会占用字节。
4.2 synchronized锁升级的过程
- synchronized锁信息存放在markword中
- 有一个锁升级的过程:
- 无锁(刚创建对象时)、偏向锁(无锁、自旋锁、自适应锁)、轻量级锁、重量级锁
- 升级过程与markword关系密切(markword一共8byte, 64bit)
- 锁状态
当前状态 | 1bit 偏向锁位 | 2bit 锁标志位 |
---|---|---|
无状态 (new) | 0 | 01 |
偏向锁 | 1 | 01 |
轻量级锁 | - | 00 |
重量级锁 | – | 10 |
GC标记信息 | – | 11 |
- 升级明细过程
a. 默认synchronized(0)
00-> 轻量级锁
默认情况下,偏向锁有个时延,默认是4S。 因为JVM自己有一些默认的启动线程,里面有很多sync代码,这些ysnc代码启动时就知道肯定会有竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低
b. 如果设置 -XX:BiasedLockingStartDealy=0, 则newObject() ->101偏向锁 -> 线程ID为0, ->Anonymous BiasedLock 打开偏向锁,new处理的对象,默认就是一个可偏向锁你们对象101
c.如果有线程上锁
上偏向锁,指的就是把markword的线程ID改成自己的线程ID的过程。 偏向锁不可重偏向、批量偏向、批量撤销
d.如果有线程竞争
撤销偏向锁,升级到轻量级锁
线程再自己的线程栈生成LockRecord,用CAS操作将markword设置为指向自己的这个线程的LR的指针,设置成功者得到锁。
e.如果竞争加剧
竞争加剧:有线程超过10次自旋,-XX PreBlockSpin,或者自旋线程数超过CPU核数的一半,1.6之后加入自适应自旋 。如果太多线程自旋会导致CPU消耗过大,不如升级为重量级锁进入等待队列(不在消耗CPU)。Adapative Self Spinning, JVM自己控制
升级到重量级锁-> 向OS申请资源,linux mutex, CPU从3级-0级系统调用,线程挂起,进入等待队列,等到OS的调度,然后再映射回用户空间。涉及到用户态切换到内核态,再切换回到用户态的过程。
- 锁降级
在GC发生时会存在,除了GC线程访问,其他线程不会访问,所以降级没有意义了。可以认为没有降级也可以。 - 锁消除 lock eliminate
- 示例
private String add(String s1,String s2){
StringBuffer sf = new StringBuffer();
sf.append(s1).append(s2);
return sf.toString();
}
- StringBuffer的 append方法是被synchronized修改过的,但在上面的代码中,sf只会在add方法内部引用,不可能被其他线程引用(因为是局部变量,线程栈私有),因此此处的sf是不可能共享的资源,JVM会自动消除StringBuffer对象内部的锁。
- 锁粗化 lock coarsening
- 示例
private String test(String str) {
int i = 0;
StringBuffer sf = new StringBuffer();
while (i < 1000) {
sf.append(str);
i++;
}
return sf.toString();
}
- 解释:JVM会检测到这样一辆车的操作都是对同一个对象加锁(while 循环内100次执行append,)此时JVM就会将加锁的范围粗化到这一连串的操作的外部(比如while虚幻体外),使得这一连串的操作只需要加一次锁即可。
- synchronized vs Lock(CAS)
- 在高竞争 高耗时的环境下 synchronized效率更高
- 在低竞争 低耗时的环境下CAS效率更高
- synchronized 到重量级之后是 等待队列(不消耗CPU),CAS(等待时间消耗CPU)
- synchronized实现过程
- java代码: synchronized关键字
- class字节码层面:monitorenter,monitorexit
即只添加了这两个指令 - 执行过程中自动升级:
- 在CPU汇编层级,使用lock comxchg 指令来实现保证原子操作,锁实现。
4.2 计算机基础
- 超线程
- 一个ALU对应多个PC核Register组,即所谓的四核八线程
- 进程:资源分配的基本单位
- 线程:CPU进行执行调度的基本单位
- cache line
- 按照块来读,目前是64Byte,即一行数据64字节;
- 比如MESI协议:cpu的每个cache line标记四种状态(额外的2个bit);
- 其他缓存一致性协议:MSI MESI MOSI Synapse Firefly Dragon等。
4.3 volatile关键字
- 作用
- 保证一个变量的CPU可见性,保证线程可见性。
- 超线程