二轮复盘并发编程!!文末有一些我学习并发编程的感受,不知道怎么入手的可以看看,正所谓传道授业解惑也,传递怎么学比知识更重要!欢迎在评论区和我交流讨论
什么是线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
线程安全,是编程中的术语,指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
– 通俗的说法
-
不可变,不可变 (Immutable)的对象一定是线程安全的,定义时使用final关键字修饰 它就可以保证它是不可变的。
-
绝对线程安全,在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。
// 这里会出现另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用,再用i访问数组就会抛出一个 ArrayIndexOutOfBoundsException异常 public class VectorTestCase_1 { private static Vector<Integer> vector = new Vector<Integer>(); public static void main(String[] args) { while (true) { for (int i = 0; i < 10; i++) { vector.add(i); } Thread removeThread = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < vector.size(); i++) { vector.remove(i); } } }); Thread printThread = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < vector.size(); i++) { System.out.println((vector.get(i))); } } }); // 单个操作是线程安全的,并不代表组合(compound)的方法调用就跟synchronized一样线程安全。 removeThread.start(); printThread.start(); //不要同时产生过多的线程,否则会导致操作系统假死 while (Thread.activeCount() > 20) ; } } } // 线程安全 while (true) { for (int i = 0; i < 10; i++) { vector.add(i); } Thread removeThread = new Thread(new Runnable() { @Override public void run() { synchronized (vector) { for (int i = 0; i < vector.size(); i++) { vector.remove(i); } } } }); Thread printThread = new Thread(new Runnable() { @Override public void run() { synchronized (vector) { for (int i = 0; i < vector.size(); i++) { System.out.println((vector.get(i))); } } } });
-
相对线程安全,保证对这个对象单次的操作是线程安 全的
-
线程兼容,对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。
-
线程对立,有害 的,应当尽量避免。
单例模式的线程安全性(重点,一般要求手写)
- 饿汉式单例模式的写法:线程安全
- 懒汉式单例模式的写法:非线程安全
- 双检锁单例模式的写法:线程安全
/**
* 懒汉模式
*/
public class Singleton {
private Singleton(){}
// 这里是null,表示懒加载,就是要调用才加载,所以就懒
// 如果这里换成new Singleton ();就是饿汉模式,饿汉就是要主动寻找。
private static Singleton instance = null;
public static Singleton getInstance(){
if (instance == null){
instance = new Singleton ();
}
return instance;
}
}
// 上面的方式会有线程安全问题
// 变种1
/**
* 懒汉模式的变形:解决线程安全问题,但是并不是绝对线程安全
*/
public class Singleton1 {
private Singleton1 (){}
private static volatile Singleton1 instance = null; // 加了volatile才是线程安全
public static Singleton1 getInstance() {
// 这里可能会出现指令重排序问题,也就是线程A执行到这里,
// 线程B分配对象的这时候刚好完成了内存空间和设置insance的内存地址,但是并没有进行到初始化这一步
// 这时候,A线程就以为B线程已经完成了,就直接return了,这时候返回一个没有初始化完成的instance对象
// 其实根本原因就是这个instance初始化并不是原子操作,需要用volatile保证其可见性
// volatile阻止了变量访问前后的指令重排序,保证了指令的执行顺序,也就是要等线程B的初始化完成,线程A才会继续执行
if (instance == null) { //双重检测机制
synchronized (Singleton.class){ //同步锁
if (instance == null) { //双重检测机制
instance = new Singleton1();
}
}
}
return instance;
}
// 2. 用静态内部类实现单例模式(饿汉模式的一种实现)
// 这种实现方式是利用classloader的加载机制来实现懒加载,并保证构建单例的线程安全。
public class Singleton {
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
// 测试:
//获得构造器
Constructor con = Singleton1.class.getDeclaredConstructor();
//设置为可访问
con.setAccessible(true);
//构造两个不同的对象
Singleton1 singleton1 = (Singleton1)con.newInstance();
Singleton1 singleton2 = (Singleton1)con.newInstance();
//验证是否是不同对象
System.out.println(singleton1.equals(singleton2)); //false
//3. 枚举
public class EnumStarvingSingleton {
private EnumStarvingSingleton(){}
public static EnumStarvingSingleton getInstance(){
// 获取enum的单例
return ContainerHolder.HOLDER.instance;
}
private enum ContainerHolder{
HOLDER;
private EnumStarvingSingleton instance;
ContainerHolder(){
instance = new EnumStarvingSingleton();
}
}
}
// 唯一的缺点就是,不是懒加载
// 优点:避免发射的破环,线程安全,因为在static中(在类加载中),实例已经被创建出来了 ,他本质也是一种饿汉模式
// 使用枚举实现的单例模式,不但可以防止利用反射强行构建单例对象,而且可以在枚举类对象被反序列化的时候,保证反序列的返回结果是同一对象
拓展知识:什么是指令重排序
这个解决流水线阻塞的技术方案。
举个例子:
a = b + c
d = a * e
x = y * z
我们可以试想一下,如果不用指令重排序,我们先执行完a,d要等a执行完才能执行,然后计算x的数据其实是准备了的,这时候,指令执行单元就会处于空闲状态,等d执行完再会执行,这样做是真的耗费时间!!!
我们看看引入指令重排序之后上面这个例子会怎么执行!
先看一个图
很清楚地说明了CPU执行指令的流程!首先是取指令和指令分发,然后保存这个保留站里面(这个保留站就相当于火车站),等待指令都分发完毕之后,确定好指令之间的依赖关系后,就可以乱序执行了(这个乱序执行的过程就相当于一个线程池,让功能单元不空闲)!然后再把结果存到ROB中。最后,实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer ),最终才会写入到高速缓存和内存里。
重点是:在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。
乱序执行,极大地提高了 CPU 的运行效率。核心原因是,现代 CPU 的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU 不得不加入 NOP 操作进行空转。而现代 CPU 的流水线级数也已经相对比较深了,到达了 14 级。这也意味着,同一个时钟周期内并行执行的指令数是很多的。
线程安全的实现方法
-
互斥同步
同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用
互斥是实现同步的一种手段,临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)都是常见的互斥实现方式
被synchronized修饰的同步块对同一条线程来说是可重入的。
被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。就不会像mysql那样拿不到所就会超时退出
-
非阻塞同步,我们上面的synchronized是同步阻塞的方法,导致我们的性能开销严重,然后就引入了一个乐观并发策略的实现,又称乐观锁,或者是无锁编程
典型的实现就是CAS操作,他的调用的unsafe包里可以找到
// 这是一个native方法,是要调用处理器指令来实现的 public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); // CAS操作同时也会引发一个ABA问题
synchronized
的底层实现原理
标准回答
synchronized 代码块是由一对monitorenter/monitorexit
指令实现的,Monitor
对象是同步的基本实现单元。
在 Java 6 之前,Monitor 的实现完全是依靠操作系统内部的互斥锁,因为需要进行用户态到内核态的切换,所以同步操作是一个无差别的重量级操作。
现代的(Oracle)JDK 中,JVM 对此进行了大刀阔斧地改进,提供了三种不同的 Monitor 实现,也就是常说的三种不同的锁:偏斜锁(Biased Locking)、轻量级锁和重量级锁,大大改进了其性能。
当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS
操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程,所以并不涉及真正的互斥锁。
如果有另外的线程试图锁定某个已经被偏斜过的对象,JVM 就需要撤销(revoke)偏斜锁,并切换到轻量级锁实现。轻量级锁依赖 CAS 操作 Mark Word 来试图获取锁,如果重试成功,就用普通的轻量级锁;否则,进一步升级为重量级锁。
当JVM 进入安全点(SafePoint)的时候,会检查是否有闲置的 Monitor,然后试图进行降级
通过分析字节码可以得到上面这两个重要的指令,关于 monitorenter
和 monitorexit
的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。简单点来说就是lock和unlock的作用。
那这里为什么需要一个锁计数器呢?
举个例子,如果一个Java 类中拥有多个 synchronized 方法,那么这些方法之间的相互调用,不管是直接的还是间接的,都会涉及对同一把锁的重复加锁操作。因此,我们需要设计这么一个可重入的特性,来避免编程里的隐式约束。
锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持
锁的升级
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。它们会随着竞争的激烈而逐渐升级。注意,锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
锁粗化
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
重量级锁
重量级锁是 Java 虚拟机中最为基础的锁实现,为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机给出的方案是自适应自旋,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目)。也就是说,上次自旋时没等到锁,那这次就自旋的时间短一点,如果上次等到了,这次自旋的时间就长一点
自旋状态还带来另外一个副作用,那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。
轻量级锁
多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒。
Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。Java 虚拟机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。
当关闭偏向锁功能或者多个线程竞争偏向锁,导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁
偏向锁
引入偏向锁主要目的是:为了在无多线程竞争的情况下,尽量减少不必要的轻量级锁执行路径。直接看图吧!
最后的总结:
可以看看这个获取锁的整个流程
拓展知识:CAS是什么
CAS 操作包含三个操作数 – 内存位置、预期数值和新值。CAS 的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作。
为什么会有CAS,先看这个图
CPU是通过这个蓝色标注的总线跟内存进行通信的,那CPU的多个核心同时对内存进行操作,就会造成并发安全问题!
举个例子:你要实现一个递增操作!很明显要进行两次访存操作。该指令包含三个操作读->改->写
- 核心1 从内存指定位置出读取数值1(假设为1),并加载到寄存器中
- 核心2 从内存指定位置出读取数值1(假设为1),并加载到寄存器中
- 核心1 将寄存器中值+1
- 核心2 将寄存器中值+1
- 核心1 将修改后的值写回内存
- 核心2 将修改后的值写回内存
很明显,最后读出来的结果是2,然而我们期待的是3!!
那JAVA就通过JNI
调用相关的接口,而CAS的逻辑是C++实现的,这里就不详细说,主要看看JAVA的实现
就是这样的一个类来封装操作!!
CPU 原子操作
CAS 可以保证一次的读-改-写操作是原子操作,在单处理器上该操作容易实现,但是在多处理器上实现就有点儿复杂了。CPU 提供了两种方法来实现多处理器的原子操作:总线加锁或者缓存加锁。
- 总线加锁:总线加锁就是就是使用处理器提供的一个 LOCK# 信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。但是这种处理方式显得有点儿霸道,不厚道,他把CPU和内存之间的通信锁住了,在锁定期间,其他处理器都不能其他内存地址的数据,其开销有点儿大。所以就有了缓存加锁。
- 缓存加锁:其实针对于上面那种情况,我们只需要保证在同一时刻,对某个内存地址的操作是原子性的即可。缓存加锁,就是缓存在内存区域的数据如果在加锁期间,当它执行锁操作写回内存时,处理器不再输 出LOCK# 信号,而是修改内部的内存地址,利用缓存一致性协议来保证原子性。缓存一致性机制可以保证同一个内存区域的数据仅能被一个处理器修改,也就是说当 CPU1 修改缓存行中的
i
时使用缓存锁定,那么 CPU2 就不能同时缓存了i
的缓存行。
ABA 问题
程1和线程2同时执行 CAS 逻辑,两个线程的执行顺序如下:
- 时刻1:线程1执行读取操作,获取原值 A,然后线程被切换走
- 时刻2:线程2执行完成 CAS 操作将原值由 A 修改为 B
- 时刻3:线程2再次执行 CAS 操作,并将原值由 B 修改为 A
- 时刻4:线程1恢复运行,将比较值(compareValue)与原值(oldValue)进行比较,发现两个值相等。然后用新值(newValue)写入内存中,完成 CAS 操作
如上流程,线程1并不知道原值已经被修改过了,在它看来并没什么变化,所以它会继续往下执行流程。对于 ABA 问题,通常的处理措施是对每一次 CAS 操作设置版本号。(像数据库的乐观锁实现就是设置版本号)
深入分析 volatile 的实现原理
一个变量如果用 volatile
修饰了,则 Java 可以确保所有线程看到这个变量的值是一致的。如果某个线程对 volatile
修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性。
那 volatile 关键字究竟代表什么含义呢?它会确保我们对于这个变量的读取和写入,都一定会同步到主内存里,而不是从 Cache 里面读取。
volatile
可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层,volatile
是采用“内存屏障”来实现的。
-
volatile
可见性:对一个volatile
的读,总可以看到对这个变量最终的写。 -
volatile
原子性:volatile
对单个读 / 写具有原子性(32 位 Long、Double),但是复合操作除外,例如i++
。 -
volatile
禁止指令重排序():- 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
为了实现
volatile
的内存语义,JMM 会限制重排序。其重排序规则如下:- 如果第一个操作为
volatile
读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile
读之后的操作,不会被编译器重排序到volatile
读之前; - 如果第二个操作为
volatile
写,则不管第一个操作是啥,都不能重排序。这个操作确保volatile
写之前的操作,不会被编译器重排序到volatile
写之后; - 当第一个操作
volatile
写,第二个操作为volatile
读时,不能重排序。
-
JVM 底层采用“内存屏障”来实现
volatile
语义。
volatile
的底层实现,是通过插入内存屏障。但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM 采用了保守策略。
策略如下:
- 在每一个
volatile
写操作前面,插入一个 StoreStore 屏障 - 在每一个
volatile
写操作后面,插入一个 StoreLoad 屏障 - 在每一个
volatile
读操作后面,插入一个 LoadLoad 屏障 - 在每一个
volatile
读操作后面,插入一个 LoadStore 屏障
原因如下:
- StoreStore 屏障:保证在
volatile
写之前,其前面的所有普通写操作,都已经刷新到主内存中。 - StoreLoad 屏障:避免
volatile
写,与后面可能有的volatile
读 / 写操作重排序。 - LoadLoad 屏障:禁止处理器把上面的
volatile
读,与下面的普通读重排序。 - LoadStore 屏障:禁止处理器把上面的
volatile
读,与下面的普通写重排序。
// 直接以一个例子分析上面的策略
public class VolatileTest {
int i = 0;
volatile boolean flag = false;
public void write() {
i = 2;
flag = true;
}
public void read() {
if (flag){
System.out.println("---i = " + i);
}
}
}
volatile
的内存屏障插入策略非常保守,其实在实际中,只要不改变 volatile
写-读的内存语义,编译器可以根据具体情况优化,省略不必要的屏障。
像上面这个LoadLoad屏障就可以省略,因为下面没有这种情况!!
JAVA内存模型
官方回答:Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。
通俗的说法:本质上可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化(关于这个禁用缓存,我们从下面的扩展知识可以知道,导致可见性和有序性的原因是CPU的高级缓存和CPU的工作机制)的方法。具体来说,这些方法包括 volatile、synchronized 和 final 三个关键字,以及 Happens-Before 规则
Java内存模型规定了所有的变量都存储在主内存,每个线程都有自己的工作内存,所以每个线程必须在自己的工作内存中取出数据,不能直接访问主内存
注意:这里的工作内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存,写缓存区,寄存器等等。
Happens-Before 规则
本质:前面一个操作的结果对后续操作是可见的,用于对可见性进行约束的
比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。
- 程序的顺序性规则,也就是程序前面对某个变量的修改一定是对后续操作可见的(同一个线程内)
- volatile 变量规则,一个 volatile 变量的写操作相对于后续对这个 volatile 变量的读操作可见(这里指其他线程可以看到前面的操作,而不是像第一条那样在一个线程内,可看传递性!!)
- 传递性
- 管程中锁的规则,对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,也就是java中的
synchronized
!- 线程 start() 规则,指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
- 线程 join() 规则,如果在线程 A 中,调用线程 B 的 join() 并成功返回,那么线程 B 中的任意操作 Happens-Before 于该 join() 操作的返回。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
被忽视的 final:final修饰的实例字段则是涉及到新建对象的发布问题,当一个对象包含final修饰的实例字段时,其他线程能够看到已经初始化的final实例字段,这是安全的。 Java 内存模型对 final 类型变量的重排进行了约束。现在只要我们提供正确构造函数没有“逸出”,就不会出问题了。
final int x; // 错误的构造函数 public FinalFieldExample() { x = 3; y = 4; // 此处就是讲 this 逸出, global.obj = this; }
三个基本原则:
原子性,简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
下面通过一个例子分析一下原子性:
public class Demo { public int sharedState; public void nonSafeAction(){ while (sharedState < 100000){ int former = 0; int latter = 0; // synchronized (this){ former = sharedState++; latter = sharedState; // } if (former != latter - 1) { System.out.println("former :" + former + "," + "latter :" + latter); } } } public static void main(String[] args) throws InterruptedException { Demo demo = new Demo(); Thread thread = new Thread(new Runnable() { @Override public void run() { demo.nonSafeAction(); } }); Thread thread1 = new Thread(new Runnable() { @Override public void run() { demo.nonSafeAction(); } }); thread.start(); thread1.start(); thread.join(); thread1.join(); } }
在两次取值的过程中,其他线程可能已经修改了 sharedState
所以我的运行结果每次都不一样!!
尝试加上
synchronized
保护起来可见性,是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,
volatile
就是负责保证可见性的。有序性,是保证线程内串行语义,避免指令重排等。也是用
volatile
,最著名的例子就是单例模式里面的 DCL(双重检查锁),上面有提到!!!需要满足以下条件才能重排序
- 在单线程环境下,不能改变程序运行的结果。
- 存在**数据依赖(仅针对单个处理器和单个线程的操作)**关系的情况下,不允许重排序。
总结:无法通过 happens-before 原则推导出来的,JMM 允许任意的排序。
as-if-serial 语义的意思是:所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守 as-if-serial 语义。注意,as-if-serial 只保证单线程环境,多线程环境下无效。
注意:
public class RecordExample1 { public static void main(String[] args){ int a = 1; int b = 2; try { a = 3; // A b = 1 / 0; // B } catch (Exception e) { } finally { System.out.println("a = " + a); } } }
按照重排序的规则,操作 A 与操作 B 有可能会进行重排序,如果重排序了,B 会抛出异常( / by zero),此时A语句一定会执行不到,那么
a
还会等于 3 么?如果按照 as-if-serial 原则它就改变了程序的结果。
其实,JVM 对异常做了一种特殊的处理,为了保证 as-if-serial 语义,Java 异常处理机制对重排序做了一种特殊的处理:JIT 在重排序时,会在catch
语句中插入错误代偿代码(a = 3
),这样做虽然会导致catch
里面的逻辑变得复杂,但是 JIT 优化原则是:尽可能地优化程序正常运行下的逻辑,哪怕以catch
块逻辑变得复杂为代价。一图总结重排序
Java内存模型底层怎么实现的?主要是通过内存屏障(memory barrier)禁止重排序的,即时编译器根据具体的底层体系架构,将这些内存屏障替换成具体的 CPU 指令。对于编译器而言,内存屏障将限制它所能做的重排序优化。而对于处理器而言,内存屏障将会导致缓存的刷新操作。比如,对于
volatile
,编译器将在volatile字段的读写操作前后各插入一些内存屏障
拓展知识:存储器层次结构
一图了解:
各个存储器只和相邻的一层存储器打交道
总结:我们常常把 CPU 比喻成高速运转的大脑,那么和大脑同步的寄存器(Register),就存放着我们当下正在思考和处理的数据。而 L1-L3 的 CPU Cache,好比存放在我们大脑中的短期到长期的记忆。我们需要小小花费一点时间,就能调取并进行处理。
我们自己的书桌书架就好比计算机的内存,能放下更多的书也就是数据,但是找起来和看起来就要慢上不少。而图书馆更像硬盘这个外存,能够放下更多的数据,找起来也更费时间。从寄存器、CPU Cache,到内存、硬盘,这样一层层下来的存储器,速度越来越慢,空间越来越大,价格也越来越便宜。
拓展知识:局部性原理
抛问题:我们能不能既享受 CPU Cache 的速度,又享受内存、硬盘巨大的容量和低廉的价格呢?
答案:存储器中数据的局部性原理(Principle of Locality)。我们可以利用这个局部性原理,来制定管理和访问数据的策略。这个局部性原理包括时间局部性(temporal locality)和空间局部性(spatial locality)这两种策略。
时间局部性:就是我们最近访问过的数据还会被反复访问。(例如:我们最常用的LRU算法(为了保证缓存命中率不过低))
空间局部性,就是我们最近访问过数据附近的数据很快会被访问到。(这个就相当于我们在淘宝浏览商品的时候,一般你要浏览的目标商品的旁边的商品都会被加载进内存,例如:推荐商品)
拓展知识:CPU Cache
为什么会引入高速缓存?(CPU Cache)
CPU 和内存的性能鸿沟越拉越大,所以直接在 CPU 中嵌入了使用更高性能的 SRAM 芯片的 Cache,来弥补这一性能差异。通过巧妙地将内存地址,拆分成**“索引 + 组标记 + 偏移量**”的方式,使得我们可以将很大的内存地址,映射到很小的 CPU Cache 地址里。
高速缓存和内存之间的映射关系
就如下图,一个CacheLine对应多个内存块,比如我们想要访问21号内存块,他就在5号缓存块中,只需要访问相应的缓存块即可!
上面这种映射关系出现了一个问题:我们怎么知道这个缓存的内容是对应着21号内存块的数据的呢?这里就要谈到下面的内容了
高速缓存的架构和访问流程
**一个内存的访问地址,最终包括高位代表的组标记、低位代表的索引,以及在对应的 Data Block 中定位对应字的位置偏移量。**而内存地址对应到 Cache 里的数据结构,则多了一个有效位和对应的数据,由“索引 + 有效位 + 组标记 + 数据”组成。如果内存中的数据已经在 CPU Cache 里了,那一个内存地址的访问,就会经历这样 4 个步骤:
- 根据内存地址的低位,计算在 Cache 中的索引;
- 判断有效位,确认 Cache 中的数据是有效的;
- 对比内存访问地址的高位,和 Cache 中的组标记,确认 Cache 中的数据就是我们要访问的内存数据,从 Cache Line 中读取到对应的数据块(Data Block);
- 根据内存地址的 Offset 位,从 Data Block 中,读取希望读取到的字。
如果在 2、3 这两个步骤中,CPU 发现,Cache 中的数据并不是要访问的内存地址的数据,那 CPU 就会访问内存,并把对应的 Block Data 更新到 Cache Line 中,同时更新对应的有效位和组标记的数据。
拓展知识:CPU 高速缓存的写入
先看看这个CPU高速缓存的结构
第一个问题是,写入 Cache 的性能也比写入主内存要快,那我们写入的数据,到底应该写到 Cache 里还是主内存呢?如果我们直接写入到主内存里,Cache 里的数据是否会失效呢?
写直达(Write-Through)策略(相当于
volatile
关键字 )性能较差写回(Write-Back),性能较高,是直接写入到Cache中,等待下一次来写请求的时候再把脏数据写回到主存
拓展知识:MESI协议
上面我们引入了CPU高速缓存的架构,这里我再把图放出来
因为 CPU 的每个核各有各的缓存,互相之间的操作又是各自独立的,就会带来缓存一致性的问题。
缓存一致性问题
直接上图
核心一修改了数据,然后1 号核心希望在这个 Cache Block 要被交换出去的时候,数据才写入到主内存里。如果我们的 CPU 只有 1 号核心这一个 CPU 核,那这其实是没有问题的。不过,我们旁边还有一个 2 号核心呢!这个时候,2 号核心尝试从内存里面去读取 iPhone 的价格,结果读到的是一个错误的价格。这个问题,就是所谓的缓存一致性问题,1 号核心和 2 号核心的缓存,在这个时候是不一致的。
如何解决?
第一点叫写传播。写传播是说,在一个 CPU 核心里,我们的 Cache 数据更新,必须能够传播到其他的对应节点的 Cache Line 里。
第二点叫事务的串行化,事务串行化是说,我们在一个 CPU 核心里面的读取和写入,在其他的节点看起来,顺序是一样的。
事务串行化,其实在数据库中也经常用到!!需要做到两点,第一点是一个 CPU 核心对于数据的操作,需要同步通信给到其他 CPU 核心。第二点是,如果两个 CPU 核心里有同一个数据的 Cache,那么对于这个 Cache 数据的更新,需要有一个“锁”的概念。只有拿到了对应 Cache Block 的“锁”之后,才能进行对应的数据更新。
第一点:本质上就是把所有的读写请求都通过总线(Bus)广播给所有的 CPU 核心,然后让各个核心去“嗅探”这些请求,再根据本地的情况进行响应。
那如何写,常用的也分两种!
一种叫作写失效(Write Invalidate)的协议。就是把“失效”请求告诉所有其他的 CPU 核心,让其他CPU核心来判断自己是否也有一个失效版本的 Cache Block,然后把这个也标记成失效的就好了。
另一种是写广播(Write Broadcast)的协议。它和上面唯一的不同点是,他会同步更新数据而不是标记失效
谈谈MESI 协议
- **M:代表已修改(Modified)**这个就是之前提到的cache脏数据!!
- E:代表独占(Exclusive),无论是独占状态还是共享状态,缓存里面的数据都是“干净”的。对应的 Cache Line 只加载到了当前 CPU 核所拥有的 Cache 里。如果收到了一个来自于总线的读取对应缓存的请求,它就会变成共享状态。
- S:代表共享(Shared),当我们想要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他 CPU 核心里面的 Cache,都变成无效的状态,然后再更新当前 Cache 里面的数据。
- I:代表已失效(Invalidated),这个就是上面提到的标志失效
对应流程图
AQS是什么
看看基本原理:
在 AQS 内部,通过维护一个
FIFO 队列
来管理多线程的排队工作。在公平竞争的情况下,无法获取同步状态的线程将会被封装成一个节点,置于队列尾部。入队的线程将会通过自旋的方式获取同步状态,若在有限次的尝试后,仍未获取成功,线程则会被阻塞住。大致示意图如下:当头结点释放同步状态后,且后继节点对应的线程被阻塞,此时头结点
线程将会去唤醒后继节点线程。后继节点线程恢复运行并获取同步状态后,会将旧的头结点从队列中移除,并将自己设为头结点。大致示意图如下:
–引用博客
独占模式分析
一图总结:
共享模式分析
一图总结:
Condition 实现原理
ConditionObject
是通过基于单链表的条件队列来管理等待线程的。线程在调用await
方法进行等待时,会释放同步状态。同时线程将会被封装到一个等待节点中,并将节点置入条件队列尾部进行等待。当有线程在获取独占锁的情况下调用signal
或singalAll
方法时,队列中的等待线程将会被唤醒,重新竞争锁。另外,需要说明的是,一个锁对象可同时创建多个 ConditionObject 对象,这意味着多个竞争同一独占锁的线程可在不同的条件队列中进行等待。在唤醒时,可唤醒指定条件队列中的线程。其大致示意图如下:
唤醒和阻塞
JUC工具包-锁
百度脑图地址:http://naotu.baidu.com/file/24195617c1647f77ab4a944cd555a62b?token=5ffdeb24c24234c5
独占锁 ReentrantLock 的原理
ReentrantLock 是可重入的独占锁, 同时只能有一个线程可以获取该锁,其他获取该锁的线程会被阻塞而被放入该锁的 AQS 阻塞队列里面。ReentrantLock 最终还是使用 AQS 来实现的,默认是非公平锁。
// 非公平
final void lock() {
// cas 给 state 赋值
if (compareAndSetState(0, 1))//CAS获取锁
// cas 赋值成功,代表拿到当前锁,记录拿到锁的线程
setExclusiveOwnerThread(Thread.currentThread());//锁住当前线程
else
// acquire 是抽象类AQS的方法,
// 会再次尝试获得锁,失败会进入到同步队列中
acquire(1);
}
// 非公平下 把1传进去
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); //获取当前线程
int c = getState(); //拿到当前有多少把锁
// 同步器的状态是 0,表示同步器的锁没有人持有
if (c == 0) {
//如果没有锁
if (compareAndSetState(0, acquires)) {//将需要的锁的数目加上去
setExclusiveOwnerThread(current);
return true;
}
}
//体现可重入锁的性质
else if (current == getExclusiveOwnerThread()) {//如果已经有锁了,就判断当前线程是否是已经被锁住的线程
int nextc = c + acquires; 当前线程持有锁的数量 + acquires
// int 是有最大值的,<0 表示持有锁的数量超过了 int 的最大值
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
//否则线程进入同步队列
return false;
}
// 其实上面所说的非公平的意思就是 不限定谁先到就先抢到,谁刚刚好就行
// 例如:A获取锁,然后A被阻塞 进入了同步队列,然后B获取锁,恰好这时候,锁的持有者释放了锁,这时候B恰好CAS成功就获取到了锁
// 公平
final void lock() {
// acquire 是 AQS 的方法,表示先尝试获得锁,失败之后进入同步队列阻塞等待
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// hasQueuedPredecessors 是实现公平的关键
// 会判断当前线程是不是属于同步队列的头节点的下一个节点(头节点是释放锁的节点)
// 如果是(返回false),符合先进先出的原则,可以获得锁
// 如果不是(返回true),则继续等待
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 可重入锁
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
// 实现公平的关键
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
trylock,尝试获取锁,如果当前该锁没有被其他线程持有,则当前线程获取该锁井返回 true, 否则返回 false。注意,该方法不会引起当前线程阻塞。
public boolean tryLock() {
// 入参数是 1 表示尝试获得一次锁
return sync.nonfairTryAcquire(1);
}
// 尝试获得非公平锁
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); //获取当前线程
int c = getState(); //拿到当前有多少把锁
// 同步器的状态是 0,表示同步器的锁没有人持有
if (c == 0) {
//如果没有锁
if (compareAndSetState(0, acquires)) {//将需要的锁的数目加上去
setExclusiveOwnerThread(current);
return true;
}
}
//体现可重入锁的性质
else if (current == getExclusiveOwnerThread()) {//如果已经有锁了,就判断当前线程是否是已经被锁住的线程
int nextc = c + acquires; 当前线程持有锁的数量 + acquires
// int 是有最大值的,<0 表示持有锁的数量超过了 int 的最大值
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
//否则线程进入同步队列
return false;
}
读写锁 ReentrantReadWritelock 的原理
ReentrantReadWriteLock 则需要维护读状态和写状态,一个 state 怎么表示写和读两种状态呢?ReentrantReadWriteLock 巧妙地使用 state 的高 16 位表示读状态,也就是获取到读锁的次数;使用低 16 位表示获取到写锁的线程的可重入次数。
static final int SHARED_SHIFT = 16;
// 共享锁(读锁)状态单位值65536
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
//
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
/** Returns the number of shared holds represented in count */
// 获取读锁的状态
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
/** Returns the number of exclusive holds represented in count */
// 获取写锁的状态
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
readHolds 是 ThreadLocal 变量, 用来存放除去第一个获取读锁线程外的其他线程获取 读锁的可重入次数。
private transient ThreadLocalHoldCounter readHolds;
写/读锁的获取与释放
// 自己实现的获取
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
// c !=0说明读锁或者写锁已经被某线程获取
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// w=O说明已经有线程获取了读锁 , w ! =O并且当前线程不是写锁拥有者,则返回false
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 说明当前线程获取了写锁,判断可重入次数
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 设置可重入次数
setState(c + acquires);
return true;
}
// 当前没有获取到任何锁
// 第一个写线程获取写锁 writerShouldBlock非公平锁的实现总是返回false,所以就要看CAS操作
// writerShouldBlock公平锁实现了hasQueuedPredecessors,判断当前线程节点是否有前驱节点,如果有则当前线程放弃获取写锁的权限, 直接返回 false
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
// 这里再看公平和非公平的writerShouldBlock
// 公平的,就看队列的前置节点是否存在,若存在就true,不存在就false
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
// 非公平的,因为它不需要看队列的前置节点一个个排着队来,直接就可以插队了,所以就不用管这个阻塞队列了
final boolean writerShouldBlock() {
return false; // writers can always barge
}
// 写锁释放
protected final boolean tryRelease(int releases) {
// 看是否是写锁拥有者调用的unlock
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 获取可重入值, 这里没有考虑高16位, 因为获取写锁时读锁状态值肯定为0
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
// 如采写锁可重入值为0则释放锁,否则只是简单地更新状态值
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
// 获取读锁
protected final int tryAcquireShared(int unused) {
// 获取当前状态值
Thread current = Thread.currentThread();
int c = getState();
// 判断是否写锁被占用
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 获取读锁计数
int r = sharedCount(c);
// 尝试获取锁 ,多个读线程只有一个会成功,不成功的进入fullTryAcquireShared进行重试
// 非公平锁的 readerShouldBlock 实现 如果队列里面存在一个元素,则判断第一个元素是不是正在尝试 获取写锁,如果不是, 则当前线程判断当前获取读锁的线程是否达到了最大值。 最后执行 CAS 操作将 AQS 状态值的高 16 位值增加 l 。
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// 第一个线程获取读锁
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
// 如采当前线程是第一个获取读锁的线程
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
// 记录最后一个获取读锁的线程或记录其他线程读锁的可重入数
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// 类似tryAcqu工reShared,但是自旋获取
return fullTryAcquireShared(current);
}
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
// 循环直到自己的读计数-1 , CAS更新成功
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT; //减去一个读锁的单位,相当于减去一个读锁
if (compareAndSetState(c, nextc))// CAS更新成功,就判断是不是0,是0则说明当前线程已经没有读锁了,返回true,然后就在AQS中执行doreleaseShared来获取写锁被阻塞的线程,如果不为0,就是当前线程还有读锁就返回false,则会自旋重试
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}
StampedLock
StampedLock
和ReadWriteLock
相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁(数据库通过一个version字段来实现乐观锁,每一次增删改数据库都会把version值+1,如果还在遍历的话就判断当前version值是否被改变了,如果被改变了就说明数据已修改,就不会把数据读出来)。
乐观锁的实现:
// 这个是这个类的特点,乐观读锁
public long tryOptimisticRead() {
long s;
// 只是简单的位运算,并没有用CAS操作,是同时由于没有使用真正的锁,在保证数据一致性上需要复制一 份要操作的变量到方法钱,并且在操作数据时可能其他写线程己经修改了数据,而 我们操作的是方法战里面的数据,也就是一个快照,所以最多返回 的不是最新的数 据,但是一致性还是得到保障的。
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
// 三种锁之间的转换
// 锁转换-晋升写锁
// 以下三种情况可以升级成功
// 1.当前锁己经是写锁模式了 。
// 2. 当前锁处于读锁模式,并且没有其他线程是读锁模式
// 3. 当前处于乐观读模式,井且当前写锁可用 。
public long tryConvertToWriteLock(long stamp) {
long a = stamp & ABITS, m, s, next;
while (((s = state) & SBITS) == (stamp & SBITS)) {
if ((m = s & ABITS) == 0L) {
if (a != 0L)
break;
if (U.compareAndSwapLong(this, STATE, s, next = s + WBIT))
return next;
}
else if (m == WBIT) {
if (a != m)
break;
return stamp;
}
else if (m == RUNIT && a != 0L) {
if (U.compareAndSwapLong(this, STATE, s,
next = s - RUNIT + WBIT))
return next;
}
else
break;
}
return 0L;
}
另外, StampedLock 的读写锁都是不可重入锁,所以在获取锁后释放锁前不应该再调用会获取锁的操作,以避免造成调用线程被阻塞。当多个线程同时尝试获取读锁和写锁 时,谁先获取锁没有一定的规则,完全都是尽力而为,是随机的。并且该锁不是直接实现 Lock 或 ReadWriteLock 接口 ,而是其在内部自己维护了一个双向阻塞队列。
使用方法
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
// 注意这里的顺序不能错
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 1. 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
try {
Thread.sleep (50);
} catch (InterruptedException e) {
e.printStackTrace ();
}
// 2. 复制变量到线程本地堆栈
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 3. 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); //4. 获取一个悲观读锁
try {
// 5. 复制变量到线程本地堆栈
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 6. 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
JUC工具包-并发容器
不知道为啥导不出图片!!!想看的下面地址可以看
百度脑图地址:http://naotu.baidu.com/file/93eac635c8f54b23e8623e157569e01e?token=87ba341483fd1422
JUC工具包-并发队列
百度脑图地址:http://naotu.baidu.com/file/e4ff1430e7a5deadb02b5cbdfc6a48cb?token=d45a2620bd9116d5
CopyOnWriteArrayList
CopyOnWriteArrayList是一个线程安全的 ArrayList,对其进行的修改操作都是在底层的一个复制的数组(快照)上进行的, 也就是使用了写时复制策略。
public class CopyonWritelist {
public static void main (String[] args) throws InterruptedException {
CopyOnWriteArrayList<String> arrayList = new CopyOnWriteArrayList<> ();
arrayList.add ("hello");
arrayList.add ("professor");
arrayList.add ("welcome");
Thread thread = new Thread (() -> {
arrayList.set (1, "baba");
arrayList.remove (2);
arrayList.remove (0);
});
Iterator<String> iterator = arrayList.iterator ();
thread.start ();
thread.join ();
while (iterator.hasNext ()){
System.out.println (iterator.next ());
}
}
}
// 你认为这里的结果是什么,正常来说,如果是普通的List,在创建了迭代器之后还在修改数据,是会抛出异常的
// 但是正是因为这个写时复制策略,让这个迭代器,不抛出异常,而是正常读下去,读出来的值是旧值,这种就是弱一致性,就相当于你再创建迭代器的时候创建了一个快照,让你继续读下去,期间的修改你不用管
如何保证线程安全,比如多个线程进行读写时如何保证是线程安全的 ?
/*
* 添加元素到数组尾部
* 为什么需要拷贝数组,而不是在原来数组上面进行操作呢?
* 1. volatile 关键字修饰的是数组,如果我们 简单的在原来数组上修改其中某几个元素的值,是无
法触发可见性的,我们必须通过修改数组的内存地址才行,也就说要对数组进行重新赋值才行。
2. 在新的数组上进行拷贝,对老数组没有任何影响,只有新数组完全拷贝完成之后,外部才能
访问到,降低了在赋值过程中,老数组数据变动的影响。
*/
1. private transient volatile Object[] array;
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
// 得到所有的原数组
Object[] elements = getArray();
int len = elements.length;
// 拷贝到新数组里面,新数组的长度是 + 1 的,因为新增会多一个元素
Object[] newElements = Arrays.copyOf(elements, len + 1);
// 在新数组中进行赋值,新元素直接放在数组的尾部
newElements[len] = e;
// 替换掉原来的数组
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
// 再来看看在指定位置的添加操作,稍微复杂一点,
public void add(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
if (index > len || index < 0)
throw new IndexOutOfBoundsException("Index: "+index+
", Size: "+len);
Object[] newElements;
// len:数组的长度、index:插入的位置
int numMoved = len - index;
// 如果要插入的位置正好等于数组的末尾,直接拷贝数组即可
if (numMoved == 0)
newElements = Arrays.copyOf(elements, len + 1);
else {
// 如果要插入的位置在数组的中间,就需要拷贝 2 次
// 第一次从 0 拷贝到 index。
// 第二次从 index+1 拷贝到末尾。
newElements = new Object[len + 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index, newElements, index + 1,
numMoved);
}
// index 索引位置的值是空的,直接赋值即可。
newElements[index] = element;
setArray(newElements);
} finally {
lock.unlock();
}
}
// 这个删除操作也是,每一次操作数组都是要复制一份
public E remove(int index) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
// 先得到老值
E oldValue = get(elements, index);
int numMoved = len - index - 1;
// 如果要删除的数据正好是数组的尾部,直接删除
if (numMoved == 0)
setArray(Arrays.copyOf(elements, len - 1));
else {
// 如果删除的数据在数组的中间,分三步走
Object[] newElements = new Object[len - 1];
System.arraycopy(elements, 0, newElements, 0, index);
System.arraycopy(elements, index + 1, newElements, index,
numMoved);
// 复制完就可以用新的数组了
setArray(newElements);
}
return oldValue;
} finally {
lock.unlock();
}
}
// 看看获取操作,是怎么保持弱一致性的
// 这里分成了两步 先获取整个快照数组,然后通过传进来的index在这个数组上面取值
// 这里get操作的妙处就是不用加锁操作,直接就是操作快照,如果期间有线程来操作数组也不怕,依然会保持弱一致性
private E get(Object[] a, int index) {
return (E) a[index];
}
public E get(int index) {
return get(getArray(), index);
}
public E set(int index, E element) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
E oldValue = get(elements, index); // 获取指定位置的元素
if (oldValue != element) {
int len = elements.length;
Object[] newElements = Arrays.copyOf(elements, len);
newElements[index] = element;
setArray(newElements);
} else {
// Not quite a no-op; ensures volatile write semantics
// 为了保证bolatile还是要拷贝数组
setArray(elements);
}
return oldValue;
} finally {
lock.unlock();
}
}
Threadlocal
看代码
/**
* ThreadLocal的使用
*/
public class ThreadLocal1 {
/**
* 打印
* @param str
*/
static void print(String str){
System.out.println (str +":"+ localVariable.get ());
// localVariable.remove ();
}
static ThreadLocal<String> localVariable = new ThreadLocal<> ();
public static void main (String[] args) {
Thread thread = new Thread (() -> {
localVariable.set ("threadOne local variable");
print ("threadOne");
System.out.println ("threadOne remove after" + ":" + localVariable.get ());
});
Thread thread1 = new Thread (() -> {
localVariable.set ("threadTwo local variable");
print ("threadTwo");
System.out.println ("threadTwo remove after" + ":" + localVariable.get ());
});
thread.start ();
thread1.start ();
}
}
//结果:threadOne:threadOne local variable
//threadOne remove after:threadOne local variable
//threadTwo:threadTwo local variable
//threadTwo remove after:threadTwo local variable
我们发现,每一个线程使用的threadLocal都不一样,所以打印的东西都不一样,那到底是什么工作原理呢?
多钱程访问同一个共享变量时特别容易出现并发问题,然而我们一般是使用加锁的措施,但是使用锁又要使用得很谨慎,要对锁有一定的了解,那我们可以通过ThreadLocal 当创建一个变量后, 每个线程对其进行访问的时候访问的是自己线程的变量
就像上图一样,每一个线程都会复制一个资源到本地线程,自己操作自己的资源,从而避免了线程安全问 题
Threadlocal 的实现原理
从源码上看,Threadlocal内部有一个ThreadMap类,其实是是一个定制化的 Hashmap,因为每个线程可以关联多个 ThreadLocal 变量,所以这个map是用来保存ThreadLocal 变量
看看我们上面用到的set、get和remove方法
// set 操作每个线程都是串行的,不会有线程安全的问题
public void set(T value) {
Thread t = Thread.currentThread();
// 将当前线程作为 key,去查找对应的线程变量,找到则设置
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
// 初始化ThreadLocalMap
else
createMap(t, value);
}
// 我们来看看getMap做了什么
// getMap就是返回了当前的threadLocals
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
//那createMap自然就是创建本地的threadLocals
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
public T get() {
// 因为 threadLocal 属于线程的属性,所以需要先把当前线程拿出来
Thread t = Thread.currentThread();
// 从线程中拿到 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 从 map 中拿到 entry,由于 ThreadLocalMap 在 set 时的 hash 冲突的策略不同,导致拿的策略也不同
ThreadLocalMap.Entry e = map.getEntry(this);
// 如果不为空,读取当前 ThreadLocal 中保存的值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 否则给当前线程的 ThreadLocal 初始化,并返回初始值 null
return setInitialValue();
}
private T setInitialValue() {
// 初始化为null
T value = initialValue();
Thread t = Thread.currentThread();
// 根据当前线程获得threadlocal变量
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
protected T initialValue() {
return null;
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
/**
* Remove the entry for key.
*/
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
看了上面的方法,其实就和map的操作是类似的
总结一下:在每个线程内部都有一个名为 threadLocals 的成员变量, 该变量的类型为 HashMap, 其中 key 为我们定义的 ThreadLocal 变量的 this 引用 , value 则为我 们使用 set 方法设置的值。 每个线程的本地变量存放在线程自己的内存变量 threadLocals 中, 如果当前线程一直不消亡, 那么这些本地变量会一直存在, 所以可能会造成内存溢出(线程池中的Thread持有的ThreadLocalMap一直都不会被回收,再加上ThreadLocalMap中的Entry对ThreadLocal是弱引用(WeakReference),所以只要ThreadLocal结束了自己的生命周期是可以被回收掉的。但是Entry中的Value却是被Entry强引用的,所以即便Value的生命周期结束了,Value也是无法被回收的,从而导致内存泄露。) , 因此**使用完毕后要记得调用 ThreadLocal 的 remove 方法手动释放(try-finally)**删除对应线程的 threadLocals 中的本地变量
补充:Java的实现中Thread持有ThreadLocalMap,而且ThreadLocalMap里对ThreadLocal的引用还是弱引用(WeakReference),所以只要Thread对象可以被回收,那么ThreadLocalMap就能被回收
如果ThreadLocal持有的Map会持有Thread对象的引用,这就意味着,只要ThreadLocal对象存在,那么Map中的Thread对象就永远
不会被回收。ThreadLocal的生命周期往往都比线程要长,所以这种设计方案很容易导致内存泄露。
Threadlocal 不支持继承性
/**
* ThreadLocal不支持继承
*/
public class ThreadLocal2 {
//这里使用了多态,就是为了可以使用InheritableThreadLocal重写父类的方法
// 如果要使用子类的特定方法,就要向下转型,把该对象强转为子类
// 多态的三大前提:1、存在继承关系 2、子类要重写父类的方法 3、父类数据类型的引用指向子类对象。
// public static ThreadLocal<String> threadLocal = new InheritableThreadLocal<> ();
public static ThreadLocal<String> threadLocal = new ThreadLocal<> ();
public static void main (String[] args) {
threadLocal.set ("hello world");
Thread thread = new Thread (() -> System.out.println ("thread:" + threadLocal.get ()));
thread.start ();
System.out.println ("main"+threadLocal.get ());
}
}
// 结果就是主线程可以获取到变量值,子线程就获取不到,这就是因为子线程的ThreadLocal并没有继承父线程的ThreadLocal的变量,(跟我们一般的Thread不一样,一般的子线程都是会继承父线程的所有属性)
// 如果我们打开注释就可以继承了,那这个InheritableThreadLocal内部是什么原理呢
// 重写了三个方法,让子线程可以继承父线程的变量值
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
protected T childValue(T parentValue) {
return parentValue;
}
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}
// 那为什么要继承这三个属性,我们先来看看创建线程的默认构造函数,主要看重点的
// 当父线程的 inheritableThreadLocals 的属性值不为空时
// 会把 inheritableThreadLocals 里面的值全部传递给子线程
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
// Thread类定义的inheritableThreadLocals默认为null
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
// createInheritedMap再ThreadLoacl
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
return new ThreadLocalMap(parentMap); //调用了ThreadLocalMap的构造函数
}
// ThreadLocalMap的构造函数
// 调用重写的方法,所以这个值就在这个子线程里卖了
Object value = key.childValue(e.value);
// 这样就一目了然了,set和get方法也类似用到了重写的方法,那ThreadLocal换成了inheritableThreadLocals
那么在什么情况下需要子线程可以获取父线程的 threadlocal 变量呢?情况还是蛮多的,比如子线程需要使用存放在 threadlocal 变量中的用户登录信息,再比如一些中间件需要把统一的 id 追踪的整个调用链路记录下来
JDK线程池
百度脑图地址:http://naotu.baidu.com/file/928d08b2e95904fd14bff3a5bf571088?token=b11215d75ee2e250
线程池的基本概念就不多说了,就是方便的复用线程,避免了频繁创建和销毁线程所带来的开销。就是一个池化技术有acquire
和release
方法来获取和释放连接,但是线程池却没有!!
这里重点说一下线程池是一种生产者-消费者模式。线程池的使用方是生产者,线程池本身是消费者。与一般的池化技术不同,一般的
看看JDK线程池的继承体系,以ScheduledThreadPoolExecutor
为例
最顶层的接口 Executor 仅声明了一个方法execute
。ExecutorService 接口在其父类接口基础上,声明了包含但不限于shutdown
、submit
、invokeAll
、invokeAny
等方法。至于 ScheduledExecutorService 接口,则是声明了一些和定时任务相关的方法,比如 schedule
和scheduleAtFixedRate
。线程池的核心实现是在 ThreadPoolExecutor 类中,我们使用 Executors(可以快速创建一个线程池)
调用newFixedThreadPool
、newSingleThreadExecutor
和newCachedThreadPool
等方法创建线程池均是 ThreadPoolExecutor
类型(这种创建方式不太常用)。
线程池原理分析
线程池的核心实现即 ThreadPoolExecutor 类
看看构造函数
public ThreadPoolExecutor(int corePoolSize, // 核心线程数,当线程小于这个数的时候就会优先创建新线程执行新任务
int maximumPoolSize, // 线程池维护的最大线程数
long keepAliveTime, // 空闲线程存活时间
TimeUnit unit,
BlockingQueue<Runnable> workQueue, // 任务队列,用于缓存未执行任务的队列
ThreadFactory threadFactory, // 线程工厂,一般是给线程自定义名字
RejectedExecutionHandler handler) // 拒绝策略
使用线程池要注意些什么
- 不建议使用Executors的最重要的原因是:Executors提供的很多方法默认使用的都是无界的LinkedBlockingQueue,高负载情境下,无界队列很容易导致OOM,而OOM会导致所有请求都无法处理,这是致命问题。所以强烈建议使用有界队列。
- 使用有界队列,当任务过多时,线程池会触发执行拒绝策略,线程池默认的拒绝策略会throwRejectedExecutionException 这是个运行时异常,对于运行时异常编译器并不强制catch它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用。
线程池的提交
先看看这张图,这个就是线程池任务的处理过程
再看看代码:
public Future<?> submit(Runnable task) {
if (task == null) throw new NullPointerException();
// 创建任务
RunnableFuture<Void> ftask = newTaskFor(task, null);
// 提交任务
execute(ftask);
return ftask;
}
// ThreadPoolExecutor
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
// 如果工作线程数量 < 核心线程数,则创建新线程
if (workerCountOf(c) < corePoolSize) {
// 添加工作者对象
if (addWorker(command, true))
return;
c = ctl.get();
}
// 缓存任务,如果队列已满,则 offer 方法返回 false。否则,offer 返回 true
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
// 添加工作者对象,并在 addWorker 方法中检测线程数是否小于最大线程数
else if (!addWorker(command, false))
// 线程数 >= 最大线程数,使用拒绝策略处理任务
reject(command);
}
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
// 获取当前线程状态
int rs = runStateOf(c);
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
// 内层循环,worker + 1
for (;;) {
// 线程数量
int wc = workerCountOf(c);
// 如果当前线程数大于线程最大上限CAPACITY return false
// 若core == true,则与corePoolSize 比较,否则与maximumPoolSize ,大于 return false
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
// worker + 1,成功跳出retry循环
if (compareAndIncrementWorkerCount(c))
break retry;
// CAS add worker 失败,再次读取ctl
c = ctl.get();
// 如果状态不等于之前获取的state,跳出内层循环,继续去外层循环判断
if (runStateOf(c) != rs)
continue retry;
}
}
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
// 新建线程:Worker
w = new Worker(firstTask);
// 当前线程
final Thread t = w.thread;
if (t != null) {
// 获取主锁:mainLock
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// 线程状态
int rs = runStateOf(ctl.get());
// rs < SHUTDOWN ==> 线程处于RUNNING状态
// 或者线程处于SHUTDOWN状态,且firstTask == null(可能是workQueue中仍有未执行完成的任务,创建没有初始任务的worker线程执行)
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
// 当前线程已经启动,抛出异常
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
// workers是一个HashSet<Worker>
workers.add(w);
// 设置最大的池大小largestPoolSize,workerAdded设置为true
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
// 释放锁
mainLock.unlock();
}
// 启动线程
if (workerAdded) {
t.start();
workerStarted = true;
}
}
} finally {
// 线程启动失败
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
private final class Worker extends AbstractQueuedSynchronizer
implements Runnable {
private static final long serialVersionUID = 6138294804551838833L;
// task 的thread
final Thread thread;
// 运行的任务task
Runnable firstTask;
volatile long completedTasks;
Worker(Runnable firstTask) {
//设置AQS的同步状态private volatile int state,是一个计数器,大于0代表锁已经被获取
setState(-1);
this.firstTask = firstTask;
// 利用ThreadFactory和 Worker这个Runnable创建的线程对象
this.thread = getThreadFactory().newThread(this);
}
// 任务执行
public void run() {
runWorker(this);
}
}
看看Tomcat自定义的线程池
定制版的 ThreadPoolExecutor
Tomcat 线程池扩展了原生的 ThreadPoolExecutor,通过重写 execute 方法实现了自己的任务处理逻辑:
- 前 corePoolSize 个任务时,来一个任务就创建一个新线程。
- 再来任务的话,就把任务添加到任务队列里让所有的线程去抢,如果队列满了就创建临时线程。
- 如果总线程数达到 maximumPoolSize,则继续尝试把任务添加到任务队列中去。
- 如果缓冲队列也满了,插入失败,执行拒绝策略
public class ThreadPoolExecutor extends java.util.concurrent.ThreadPoolExecutor {
public void execute(Runnable command, long timeout, TimeUnit unit) {
submittedCount.incrementAndGet();
try {
// 调用 Java 原生线程池的 execute 去执行任务
super.execute(command);
} catch (RejectedExecutionException rx) {
// 如果总线程数达到 maximumPoolSize,Java 原生线程池执行拒绝策略
if (super.getQueue() instanceof TaskQueue) {
final TaskQueue queue = (TaskQueue)super.getQueue();
try {
// 继续尝试把任务放到任务队列中去
if (!queue.force(command, timeout, unit)) {
submittedCount.decrementAndGet();
// 如果缓冲队列也满了,插入失败,执行拒绝策略。
throw new RejectedExecutionException("...");
}
}
}
}
}
定制版缓冲队列
它的目的就是在任务队列的长度无限制的情况下,让线程池有机会创建新的线程。
默认情况下 Tomcat 的任务队列是没有限制的,你可以通过设置 maxQueueSize 参数来限制任务队列的长度。
如果我们熟悉线程池的策略的话,我们的缓冲队列如果没有装满是不会增加新的线程的(在核心线程已经创建满的情况下),然而Tomcta的缓冲队列是无限长度的,所以TaskQueue 重写了 LinkedBlockingQueue 的 offer 方法,在合适的时机返回 false,返回 false 表示任务添加失败,这时线程池会创建新的线程。
public class TaskQueue extends LinkedBlockingQueue<Runnable> {
@Override
// 线程池调用任务队列的方法时,当前线程数肯定已经大于核心线程数了
public boolean offer(Runnable o) {
// 如果线程数已经到了最大值,不能创建新线程了,只能把任务添加到任务队列。
if (parent.getPoolSize() == parent.getMaximumPoolSize())
return super.offer(o):
// 执行到这里,表明当前线程数大于核心线程数,并且小于最大线程数。
// 表明是可以创建新线程的,那到底要不要创建呢?分两种情况:
//1. 如果已提交的任务数小于当前线程数,表示还有空闲线程,无需创建新线程
if (parent.getSubmittedCount()<=(parent.getPoolSize()))
return super.offer(o);
//2. 如果已提交的任务数大于当前线程数,线程不够用了,返回 false 去创建新线程
if (parent.getPoolSize()<parent.getMaximumPoolSize())
return false;
// 默认情况下总是把任务添加到任务队列
return super.offer(o);
}
}
异步编程(这块暂时没能太好理解,只把暂时可以理解的写出来!后期有待补充)
异步化,是并行方案得以实施的基础,更深入地讲其实就是:利用多线程优化性能这个核心方案得以实施的基础。
用 Java 的 NIO.2 API 来编写一个服务端程序
void listen(){
//1. 创建一个线程池
ExecutorService es = Executors.newCachedThreadPool();
//2. 创建异步通道群组
AsynchronousChannelGroup tg = AsynchronousChannelGroup.withCachedThreadPool(es, 1);
//3. 创建服务端异步通道
AsynchronousServerSocketChannel assc = AsynchronousServerSocketChannel.open(tg);
//4. 绑定监听端口
assc.bind(new InetSocketAddress(8080));
//5. 监听连接,传入回调类处理连接请求
assc.accept(this, new AcceptHandler());
}
上面的代码主要做了 5 件事情:
- 创建一个线程池,这个线程池用来执行来自内核的回调请求。
- 创建一个 AsynchronousChannelGroup,并绑定一个线程池。
- 创建 AsynchronousServerSocketChannel,并绑定到 AsynchronousChannelGroup。
- 绑定一个监听端口。
- 调用 accept 方法开始监听连接请求,同时传入一个回调类去处理连接请求。请你注意,accept 方法的第一个参数是 this 对象,就是 Nio2Server 对象本身,我在下文还会讲为什么要传入这个参数。
看看处理连接的回调类 AcceptHandler 是什么样的
//AcceptHandler 类实现了 CompletionHandler 接口的 completed 方法。它还有两个模板参数,第一个是异步通道,第二个就是 Nio2Server 本身
public class AcceptHandler implements CompletionHandler<AsynchronousSocketChannel, Nio2Server> {
// 具体处理连接请求的就是 completed 方法,它有两个参数:第一个是异步通道,第二个就是上面传入的 NioServer 对象
@Override
public void completed(AsynchronousSocketChannel asc, Nio2Server attachment) {
// 调用 accept 方法继续接收其他客户端的请求
attachment.assc.accept(attachment, this);
//1. 先分配好 Buffer,告诉内核,数据拷贝到哪里去
ByteBuffer buf = ByteBuffer.allocate(1024);
//2. 调用 read 函数读取数据,除了把 buf 作为参数传入,还传入读回调类
channel.read(buf, buf, new ReadHandler(asc));
}
// 我们看到它实现了 CompletionHandler 接口,下面我们先来看看 CompletionHandler 接口的定义。
public interface CompletionHandler<V,A> {
void completed(V result, A attachment);
void failed(Throwable exc, A attachment);
}
生产者-消费者模式
相信大家都听过这个模式了,这里我重点再提一下,因为本文在并发队列中的脑图提到了Disruptor
这就是典型的生产者消费者模式的实现!!
优点
生产者-消费者模式有一个很重要的优点,就是解耦.生产者-消费者模式还有一个重要的优点就是支持异步,并且能够平衡生产者
和消费者的速度差异。
这个两个优点在Disruptor
中完美地实现了!这里就不细说了
个人唠叨
并发编程到这里终于结束了!!!断断续续肝了接近两个星期!!
先说说这次肝并发的感受吧
这次已经算是二轮复习了!第一次学习的时候,就只看看视频,然后看看博客勉强理解一下。最后,到要接触到一些高性能并发框架的时候(Netty、Tomcat)源码的时候,发现自己的对之前的并发基础的认识又更深了一层,所以,决定看完源码之后,又回过头来肝了这一波并发基础,之后还会再出一波Tomcat和Netty源码!(期待期待)
所以,我总结了这么一套学习的方法:
接触到一个新领域,如果你学习能力不是太强,尝试通过视频,博客,书籍,对该领域的知识过一遍,以求大脑有一个印象,然后再去学怎么用(这个用是很重要的,你往往会在参考大牛的最佳实践中学到很多,并发方面的知识,强烈推荐看Tomcat和Netty的源码,这里面用到的并发知识真的是并发的最佳实践啊!),我读完之后,真的顿悟了!所以,学习大牛的最佳实践才是你理解该领域的开始,接下来,就要通过不断复盘来巩固你在该领域中的知识了!
说说前几天的同学聚会
嗯,这次同学聚会跟以往有点不同,哈哈哈!是直接到朋友家吃饭,然后到外面睡了一晚才回家!跟他们在一起,还是那种感觉!舒服!一起吃饭,一起无忧无虑地玩,好好珍惜吧!未来可期,期待下次我们再见面的时候,还是那个我们!
最近的一些启发
嗯,今天老爸去碎石,做了个手术住院了,手术不大!我今天早上也去陪我爸一个早上了,他还是一如既往地宠我,一直叫我不要来不要来!陪完他之后,我妈也过来了,一直到今晚,我妈跟我说,她不回来睡了,通宵陪我爸,然后明早一大早再去上班,真的满满的爱情,原来老夫老妻就是这样子,不需要说太多,需要的时候,我一直都在,就是这种感觉,而我们年轻人的恋爱,能做到这样子吗?我虽然只谈了一段恋爱,但是这样子的恋爱,在年轻人中并不多。前天晚上,跟朋友一起出去玩的时候,自己也说了,我其实对结婚真的没有太上心,一辈子单身也可以啊!但是,经过这一幕,我感受到了,这可能就是单身狗体会不到的幸福感吧!
再从另一个角度来剖析这件事情,我爸妈都是一样,一直都是很宠我,疫情在家,就帮我准备好一切,什么吃的都有,我喜欢什么就让我买什么,我喜欢去哪就让我去哪,从小到大都是这样!直到现在,我才体会到,你现在的安稳,只不过是有人替你负重前行罢了,没错,就是我爸妈,如果不是他们,我能学习学得这么安心吗?很明显是不可以的,正因为这些外在因素干扰少了,我才能专注在学习上!所以,我一定不能辜负他们,更不能辜负自己!!加油,明天还要去医院看爸爸,晚安!!
今天的分享就到这了,欢迎关注点赞转发,我每篇文章文末都有自己的一些个人思考,感兴趣的朋友可以看看!谢谢!
--做一个不至于技术的博主