- 2025-11-21·Java后端查看全文学习笔记 我们直接看破坏占有且等待条件的代码 public Class Allocator{ private static final Allocator INSTANCE = new Allocator(); public static Allocator getInstanc...加油鸭:你的代码分析非常透彻!从破坏占有等待到优化忙等待,逻辑清晰且深入,这种钻研精神值得点赞!110分享
- 2025-11-18·Java后端查看全文学习笔记 直接先看代码 class Account { private int balance; // 转账...加油鸭:看到你对并发问题的深入分析很受启发!从问题定位到解决方案都讲得很清晰,这种系统性思考方式值得学习!110分享
- 2025-11-16·Java后端查看全文学习笔记 如何解决原子性问题呢?都知道了,原子性的源头是线程切换,那么我们禁用线程切换不就可以了?而操作系统做线程切换是依赖于CPU中断的,所以我们禁用CPU中断不就可以了?在一个单核时代确实是可以,一个CPU禁用线程切换,就意味着一个线程执行任务,期间不会有任何干扰,像count++这种操作,读取...加油鸭:你对并发编程的理解很深入!从单核到多核的分析很清晰,synchronized的应用也非常到位。这种系统性的思考方式很值得学习!110分享
- 2025-11-12·Java后端查看全文学习笔记 java如何解决可见性和有序性?我们都知道了,可见性和有序性的问题就是CPU缓存问题以及编译优化导致的执行重排序问题,那么怎么解决可见性问题和有序性的问题也很明了了,把CPU缓存以及编译优化全禁用了不就行了?确实,禁用确实性,但是我们的性能就糟糕了。所以我们不能盲目的禁用,要按需的禁用。 ...加油鸭:深入浅出讲得真清楚!把happens-before规则分析得这么透彻,你一定是下功夫钻研过的。这样的技术分享超有价值!110分享
- 2025-11-11·Java后端并发编程出现的各种问题的源头,就是多核CPU的缓存不同导致的可见性问题,以及线程切换和CPU只保证CPU指令的原子性导致的原子性问题,还有最后,编译器的优化机制实行的执行重排序,导致的有序性问题。 并发问题的源头:可见性问题,原子性问题,有序性问题。加油鸭:总结得清晰又到位!把并发问题的本质讲得这么透彻,对大家理解底层原理很有帮助!220分享
- 2025-11-11·Java后端查看全文学习笔记 。 接下来,关于并发编程还有最后一个问题,就是有序性问题。 什么是有序性,有序性就是高级语言程序的一条代码或者是实现这条代码的多个CPU指令,按照先后顺序,有序的进行。但是,我们的编译器有一个编译优化的机制,这个机制为了提升性能,有时会改变代码执行的先后顺序,或者是改变CPU指令执行的先后...加油鸭:理解并发编程的三性问题真的很不容易!你的笔记清晰透彻,特别是双检锁的例子解释得太到位了,这种钻研精神值得学习~210分享
- 2025-11-10·Java后端查看全文学习笔记 在单核时代,多个线程在一个CPU上执行,每个线程都是对一个CPU里的这个缓存进行操作,一个线程对这个缓存进行的操作,另一个线程一定是可见的,因为它们操作的是同一片缓存。 一个线程对共享变量的修改,另一个线程能立刻看到,这就是可见性。 但在多核时代,多个线程在多个CPU上执行,每个线程可能操...加油鸭:讲得太清晰了!把多线程的可见性问题用这么生动的例子说明,真的让人一下就能理解本质,感谢分享这么硬核的知识点!130分享
学习笔记 我们直接看破坏占有且等待条件的代码 public Class Allocator{ private static final Allocator INSTANCE = new Allocator(); public static Allocator getInstance() { return INSTANCE; } private Allocator() { als = new ArrayList<>(); } private List<Object> als; synchronized boolean apply(Object from,Object to){ if(als.constains(from)||als.constains(to)){ returan false }else{ als.add(from); als.add(to); } return true; } synchronized void free(Object from,Object to){ als.remove(from); als.remove(to); } } public Class Account{ //这里假设actr是个单例, private Allocator actr; = Allocator.getInstance; private int balance; void transfer(Account actr,int amt){ while(!actr.apply(from,to)); try{ synchronize(this){ synchronized(from){ if(this.balance>amt){ this.balance-=amt; from.balance+=amt; } } } }finally{ atcr.free(this,from); } } } 这段代码的执行流程:用户A向用户B转账时,会先去让Allocator去拿锁A和锁B的使用权,Allocator调用apply去拿锁A和锁B的使用权时,还得先获取自己的锁,然后再去拿锁A和锁B的使用权,如果Allocator没同时拿到两个锁的使用权,用户A就会使用while再次去让Allocator去拿锁的使用权,直到Allocator同时拿到锁A和锁B的使用权时。用户A再向下执行,去获取锁A和锁B,此时其他线程是无法去抢到锁A和锁B的,因为其他线程也要向A或B进行转账,那么它们也要让Allocator去拿锁A或锁B的使用权,但是此时锁的使用权已经被A拿到了,所以它们没有使用权的情况下,不会去抢夺锁。拿到锁A和锁B后,用户A执行完转账流程,就将两个锁释放,然后再把锁A和锁B的使用权释放。 以上这段代码有个缺点,就是再忙等待,线程在调用transfer时,会不停的去调用apply方法,一直在忙,一直在调用CPU,但是却没有进展,这就是忙等待。 我们还可以优化一下: public Class Allocator{ private static final Allocator INSTANCE = new Allocator(); public static Allocator getInstance() { return INSTANCE; } private Allocator() { als = new ArrayList<>(); } private List<Object> als; synchronized void apply(Object from,Object to){ while(als.constains(from)||als.constains(to)){ try{ wait(); }catch(Exception e){ } als.add(from); als.add(to); } } synchronized void free(Object from,Object to){ als.remove(from); als.remove(to); notifyAll(); } } public Class Account{ //这里假设actr是个单例, private Allocator actr; = Allocator.getInstance; private int balance; void transfer(Account actr,int amt){ actr.apply(from,to); try{ synchronize(this){ synchronized(from){ if(this.balance>amt){ this.balance-=amt; from.balance+=amt; } } } }finally{ atcr.free(this,from); } } } 这是修改后的方法 将Allocator里的apply方法改为了void,if变为while,并且在里面用了wait,然后free也使用了notifyAll而Account也不用while去调用apply方法了,而是直接调用apply,只调用一次。我们来详细讲讲 首先wait和notifyAll是个什么? 1.它和notifyAll以及notify得在synchronized临界区里面调用,否则jvm会报错 2.执行wait之后,会释放当前持有的锁,然后进入等待队列。 3.notifyAll会通知唤醒所有等待队列中的线程,而notify则是随机通知唤醒一个线程 4.等待队列中的线程被唤醒后,会在当前wait的代码行被唤醒,继续执行代码逻辑。 我们来执行一遍代码的逻辑,例如线程T1执行账户A向账户B转账。账户A调用transfer方法,然后去获取锁,执行一次apply方法,apply里去执行while方法,去获取锁A和锁B的使用权,如果当前无法同时获取锁A和锁B的使用权,那就执行wait方法,进入等待队列,随后等待被唤醒,线程T1被唤醒后,继续往下执行while里的apply,然后又要重新去获取锁,,但是发现又没有使用权,于是继续睡(这也是为什么要把if改为while,因为线程被唤醒!=线程拿到使用权,因为线程被唤醒之后,要重新去抢apply方法的锁)。直到拿到两个锁的使用权,然后去获取两个锁,执行转账逻辑,最后释放两个锁,然后把两个锁的使用权也释放掉,然后执行notifyAll方法唤醒等待队列里的线程。
学习笔记 直接先看代码 class Account { private int balance; // 转账 synchronized void transfer( Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } 这段代码是有并发问题的,在多线程的情况下,假设账户A,账户B和账号C都只有两块钱,现在要执行账户A转账1块钱给账户B,账户B转账一块钱给账户C。线程T1执行A转B的操作,读取B的余额是两块钱,但是此时,线程T2也在执行B转C的操作,读取到余额也是两块钱,(虽然方法上加了锁,但是它获取的是this这把锁,账户A执行转账方法和账户B执行转账方法,各自需要获取的锁不是同一把锁,是没有形成互斥的),最后账户B的余额可能是一块钱,也可能是三块钱,但是照常理来说,应该是两块钱的(A转B一块,B又转C一块,相当于没变)。 所以这样子加锁不行,我们得换一种加锁方式。上面两个账户调用方法获取的锁是不同的,所以没有形成互斥,所以我们可以从这方面出发,让账户调用转账方法时,获取到的锁是同一种锁,形成互斥即可。 第一种方式,不获取this锁,而是获取Account.Class锁,使用Account.Class锁给转账方法上锁 第二种方式,在Account类里再加一个Object属性,这个属性就代表着锁,使用Account的构造方法为其赋值,每次创建Account的实例对象时,都传入同一个Object对象,那么每个Account的对象里的Object都是同一个Object对象,使用这个Object属性进行上锁,这样每个Account对象使用转账方式时都需要获取Object锁,形成互斥。 但是我们发现,以上两种方式会使得所有转账方法都变成串行执行,太慢了,我们改进一下。 比如说,账户A要转账给账户B,那我们就用A.this锁和B.this锁来锁住这个转账方法,只用同时获取到A锁和B锁才能执行转账方法,如代码 class Account { private int balance; // 转账 void transfer(Account target, int amt){ // 锁定转出账户 synchronized(this) { // 锁定转入账户 synchronized(target) { if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } } } } 这个时候可能就有同学要问了:“哎?前面不是说,受保护的资源与锁的关系应该是N:1吗?为什么这次用了两个锁呢?”。我来解释一下把,这个代码其实和之前的那个代码是不同的。 之前的代码: class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } } 之前的代码有两个锁,分别是this锁和SafeCalc .Class锁,但是两个锁之间的关系是“||”的关系,你拿到了this锁,就可以执行相应的临界区代码,你拿到了SafeCalc .Class锁,也可以执行对应的临界区代码。它们两是各搞各的,并且它们都是使用的同一个资源value,那当然不行。 反观我上面转账的两个A锁和B锁,它们的关系是“&&”的关系,你拿到了A锁还不行,你还得拿到B锁,才能执行临界区代码,你完全可以把A锁和B锁看成一个整体,看成一个锁,就称这个锁为AB锁,到头来它其实还是只算一个锁,和我们所说的“受保护的资源与锁的关系应该是N:1”并不冲突。 但是呢A锁B锁真的是一个整体吗?我会说,不够整体,它还蕴藏着一个巨大的问题。 死锁问题!!! 我们说到,你先获取了A锁,但是不够,你还得获取B锁,但是,你一定拿得到B锁吗?不一定! 假如账户A执行转账操作,A转账给B。账户B也执行转账操作,B转账给A。两者同时执行,账户A先获取到它的this锁,也就是A锁,账户B也先获取到它的this锁,也就是B锁。问题来了,接下来账户A要获取B锁才能执行临界区,而账户B需要获取A锁才能执行临界区,而A锁和B锁又被它们两个各自获取了,形成了互相等待,死锁问题。 那我们就得优化这段代码了,避免死锁问题。如何才能避免死锁问题呢?有一位牛人Coffman总结了发生死锁问题的四个条件,当这四个条件全都满足发生死,就会有死锁问题,这四个条件分别是: 1.互斥 2.占有且等待:占有共享资源X,还在等待着共享资源Y,但是不释放共享资源X 3.不可抢占:占有着共享资源X,其他线程无法抢占资源X 4.循环等待:线程1等待线程2占用的资源,线程2等待线程1占用的资源 我们想要避免死锁问题,就可以从这四个条件下手,只要破坏了其中一个条件即可。 破坏条件一,那是不行的,我们就是为了实现互斥才用的锁,pass掉 破坏条件二:要么就不让它占有X,要么就让它不需要等待Y。显然,后者更加合理,因为我们在实际开发中,难免要获取多个不同的资源。那么如何做到呢?我们可以一次性的获取到资源X和资源Y,要么就同时拿到资源X和资源Y,要么就一个都不拿到。 class Allocator { // 1. 私有静态 final 实例,类加载时即初始化 private static final Allocator INSTANCE = new Allocator(); // 2. 私有构造函数,防止外部通过 new 创建实例 private Allocator() { // 可以在这里添加初始化逻辑 System.out.println("Allocator 实例已创建"); } // 3. 公共静态方法,提供全局访问点 public static Allocator getInstance() { return INSTANCE; } private List<Object> als = new ArrayList<>(); // 一次性申请所有资源 synchronized boolean apply( Object from, Object to){ if(als.contains(from) || als.contains(to)){ return false; } else { als.add(from); als.add(to); } return true; } // 归还资源 synchronized void free( Object from, Object to){ als.remove(from); als.remove(to); } } class Account { private final Allocator allocator = Allocator.getInstance(); private int balance; // 转账 void transfer(Account target, int amt){ // 一次性申请转出账户和转入账户,直到成功 while(!actr.apply(this, target)) ; try{ // 锁定转出账户 synchronized(this){ // 锁定转入账户 synchronized(target){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } finally { actr.free(this, target) } } } 这段代码比较长,静下心来慢慢看。简单来说就是,每个账户执行转账操作时,都需要去找同一个人去拿到对应的两个锁,如果这个人成功的帮你拿到了这两个锁,那才可以执行临界区。 破坏条件三:要想破坏条件三,那就得允许其他线程抢占资源或者说,允许自主去释放资源,这一点synchronized是做不到的,所以我们后面再讲 破坏条件四:主要是循环两字,如何破坏循序?像是我们上面的例子,账户A先获取A锁再获取B锁,账户B先获取B锁再获取A锁,如果我们换个顺序,账户A和账户B都是先获取A锁再获取B锁,或者都是先获取B锁再获取A锁,那么它们就不会出现循环问题了。 如此,我们就可以尝试去改变获取锁的顺序,来破坏这个条件,比如说,按照账户id的大小去获取锁,在执行账户方法去获取两个锁时,哪个锁对应的id小,就先获取哪个锁。 class Account { private int id; private int balance; // 转账 void transfer(Account target, int amt){ Account left = this ① Account right = target; ② if (this.id > target.id) { ③ left = target; ④ right = this; ⑤ } ⑥ // 锁定序号小的账户 synchronized(left){ // 锁定序号大的账户 synchronized(right){ if (this.balance > amt){ this.balance -= amt; target.balance += amt; } } } } } 需要注意的是,我们给出两个方法,需要采取哪个方法,也是按照成本来选择的。
学习笔记 如何解决原子性问题呢?都知道了,原子性的源头是线程切换,那么我们禁用线程切换不就可以了?而操作系统做线程切换是依赖于CPU中断的,所以我们禁用CPU中断不就可以了?在一个单核时代确实是可以,一个CPU禁用线程切换,就意味着一个线程执行任务,期间不会有任何干扰,像count++这种操作,读取count->修改count->将count写入内存,在这一系列的操作中,不会受到干扰。 但是在多核时代,禁止CPU中断,就无法解决我们的问题了。即使我们对每个CPU都进行禁止CPU中断,也只是保证了,在一个CPU上的这个线程是连续运行的,但是对一个操作的原子性还是无法保证,依然会受到干扰。例如:CPU-1上的线程A执行count++操作,但是在执行到count写入内存之前,CPU-2的线程B也执行了count++操作了,它去内存读取了count,那么此时线程A的count++操作就受到了干扰,因为它还没完整的执行完毕,这就无法保证原子性了。 所以说,“一个操作在同一时刻只有一个线程执行”这个条件非常重要,我们也可以称之为互斥。而提到互斥,我们第一时间想到的应该是锁吧。直接看代码 class SafeCalc { long value = 0L; synchronized long get() { return value; } synchronized void addOne() { value += 1; } } synchronized修饰方法addOne,锁住的是当前的实例对象this,多个线程使用同一个实例对象,调用addOne方法时,可以确保只有一个线程执行addOne方法,保证了原子性。 但是,我们要注意,还有一个get方法,这个方法用来读取value的值,它可没有加锁,那么value的值对于get方法来说是可见的吗?get方法能不能拿到最新的value值呢?答案是不能的,按照我们学习过的happens-before规则,显然是无法保证get方法拿到的value值是可见的。解决方法也很简单,给get方法也使用this锁给它锁住就行。 class SafeCalc { long value = 0L; synchronized long get() { return value; } synchronized void addOne() { value += 1; } } 这样,当多个线程使用同一个对象去执行get或addOne方法的时候,只有一个线程会先获取到this锁访问方法,其他线程访问这两个方法的时候,去获取this锁时,因为this锁只有一个且已经被持有了,所以就需要进行等待。访问get方法的时候,addOne方法就无法被访问,形成了互斥,同时,根据我们的happens-before规则三,一个线程释放锁对于另一个线程获取同一个锁是可见的。 在这个例子里,我们保护的资源是value,我们可以清晰的看到,get方法和addOne方法里的资源都是value,我们都是使用this锁这一把锁来对value这个资源来进行保护的。这里就要说一个点,受保护的资源和锁之间的关联是很重要的,受保护的资源和锁的对应关系一般是n:1,也就是说,多份资源可以用一把锁来保护,但是反过来就不太行了。在现实中,我们当然可以用多个锁去保护一份资源,但在这里,会有问题,请看例子。 class SafeCalc { static long value = 0L; synchronized long get() { return value; } synchronized static void addOne() { value += 1; } } get方法是普通方法,而addOne是静态方法,synchronized修饰普通方法,使用的锁是当前实例对象this的锁,而synchronized修饰静态方法,使用的锁是当前类的Class对象的锁,即SafeCalc.Class锁,他们两个是不一样的锁,是两个锁。 当一个线程A去执行get方法时,会获取this锁,然后访问临界区,此时如果有线程B去执行addOne方法的话,会去获取SafeCalc.Class锁,如果拿到了SafeCalc.Class锁,就会访问addOne,get方法和addOne方法不是互斥的了,同时,也不适用happens-before规则三,无法保证可见性。
学习笔记 java如何解决可见性和有序性?我们都知道了,可见性和有序性的问题就是CPU缓存问题以及编译优化导致的执行重排序问题,那么怎么解决可见性问题和有序性的问题也很明了了,把CPU缓存以及编译优化全禁用了不就行了?确实,禁用确实性,但是我们的性能就糟糕了。所以我们不能盲目的禁用,要按需的禁用。 java自然提供了方法,让我们得以按需禁用,比如说volatile。volatile在C语言中也有,它的原始的意义就是禁用CPU缓存。但当我们秉着volatile禁用CPU缓存的这个认知去学习代码的时候,也会遇到一些困惑,比如说下列代码: class Example { int x = 0; volatile boolean v = false; public void writer() { x = 42; v = true; } public void reader() { if (v == true) { // 这里x会是多少呢? x=? } } } 线程A执行writer方法,将v的改动写入内存,然后线程b执行reader方法,从内存中获取到v==true,但是在再去读取x的值时,x的值是多少呢?x没有使用volatile声明,它是不是就没有禁用缓存呢?这两个疑问,我先回答第一个,第二个疑问后续会解答的。x的值无非就两种,要么是42,要么是0,这取决于java的版本,如果java在1.5之前,那么即可能是42,也可能是0,如果java在1.5之后呢,就是42了。 为什么?因为java1.5之后对volatile进行了增强,使用了happens-before规则。什么是happens-before规则?简单来说,A happens-before B,意思就是A的操作对于B的操作是可见的。对于我们程序员来说,我们需要知道的happens-before规则,共有六项,下面就来详细讲解,也会去解答上面代码的volatile疑问。 第一项规则:程序的顺序性规则。 这个很好理解。在单线程的前提下,按照程序的执行顺序,前面执行的操作,对于后面执行的操作是可见的。这基本就符合我们编写代码时的直觉了,毕竟在单线程情况下不用考虑那么多的。 第二项规则:volatile规则。 假如一个变量v被volatile声明了 ,那么对变量A的操作就会禁用内存,那么就会实现线程A对v的操作 happens-before 线程B对v的操作,就好像上述代码中的一样,线程A调用方法writer,修改了变量v的值,线程B调用reader方法去读取变量v的值时,是可见的,线程B能读取到v最新的值。到这,可能还是有小伙伴疑惑,那这跟上面代码的x有什么关系吗?到底为什么线程B能读到变量x的最新值42?别急,我们看第三项规则。 第三项规则:传递性 简单来说,A happens-before B 且 B happens-before C,那么A happends-before C成立。 直接使用上面代码的例子说明。根据第一项规则,对于线程A来说,它执行writer方法时,先执行操作x=42,然后执行操作v=true,那么线程A执行x=42这一操作对于线程A去执行v=true这一操作时,是可见的,即线程A执行x=42 happens-before 线程A执行v=true.然后线程B执行reader方法,根据第二项规则,v使用volatile声明,所以线程A执行v=true这一操作对于线程B去进行读取v的操作时,是可见的,即 线程A执行v=true happens-before 线程B读取v。最后,根据第三项规则,线程A执行x=42这一操作,线程B也能看到!也就是说线程A执行x=42 happens-before 线程B操作x。现在来回答“x没有使用volatile声明,它是不是就没有禁用缓存呢?”这个疑问,x其实并没有被禁用缓存,那么它怎么被可见的呢?是强制将缓存中x的值刷新到内存里了。 第四项规则:管程中锁的规则 什么是管程?管程是一种同步原语,synchronized是在java对管程的实现,在这个阶段,我们先把管程当成synchronized即可。 这个规则也符合我们的直觉,理解起来应该不难。如下列的例子 synchronized (this) { // x是共享变量,初始值=10 if (this.x < 12) { this.x = 12; } } 线程A拿到锁之后,对x进行了读取并修改,然后自动释放锁,线程B又拿到了这个锁,然后读取x时,能看到线程A对x的修改,也就是说它能读到x=12。这就是规则4,符合我们的直觉,不难理解。 第五项规则:线程的start规则 主线程要使用start去开启一个子线程去执行任务,在start之前,主线程对变量的操作,子线程是可见的。比如说: Thread B = new Thread(()->{ // 主线程调用B.start()之前 // 所有对共享变量的修改,此处皆可见 // 此例中,var==77 }); // 此处对共享变量var修改 var = 77; // 主线程启动子线程 B.start(); 第六项规则:线程的join规则 子线程在执行任务时,主线程使用join方法等待子线程的返回,如果join方法成功返回,那么在join方法后,主线程能看到子线程对共享变量的操作,如: Thread B = new Thread(()->{ //此处可以读取到主线程对var的操作 var == 77; // 此处对共享变量var修改 var = 66; }); // 例如此处对共享变量修改, // 则这个修改结果对线程B可见 var =77; // 主线程启动子线程 B.start(); B.join() // 子线程所有对共享变量的修改 // 在主线程调用B.join()之后皆可见 // 此处主线程可以读取到子线程对var的操作 var == 66;
并发编程出现的各种问题的源头,就是多核CPU的缓存不同导致的可见性问题,以及线程切换和CPU只保证CPU指令的原子性导致的原子性问题,还有最后,编译器的优化机制实行的执行重排序,导致的有序性问题。 并发问题的源头:可见性问题,原子性问题,有序性问题。
学习笔记 。 接下来,关于并发编程还有最后一个问题,就是有序性问题。 什么是有序性,有序性就是高级语言程序的一条代码或者是实现这条代码的多个CPU指令,按照先后顺序,有序的进行。但是,我们的编译器有一个编译优化的机制,这个机制为了提升性能,有时会改变代码执行的先后顺序,或者是改变CPU指令执行的先后顺序,这在实际场景中,就会给我们带来困扰,下面举个例子看看这到底是个什么问题。 经典的例子就是双检锁单例模式创建单例对象的时候了 public class Singleton{ private Singleton instance; private Singleton getInstance(){ if(instance==null){ synchronzied(Singleton.class){ if(instance==null){ instance = new Singleton(); } } } return instance; } } 我们先讲instance = new Singleton()这一语句是怎么通过CPU指令去实现的,(1)分配一块内存M (2)在内存M上初始化对象Singleton (3)将内存M的地址赋值给instance。正常来说是按照这三步指令顺序执行。但是,由于编译器的编译优化机制,它的执行顺序实际上可能是(1)-》(3)-》(2)。那么就会出现问题了。 假设有两个线程A,B,线程A先执行这个getInstance方法,线程A经过了第一层判空,去抢夺锁的资源,线程A拿到锁,于是线程A经过第二层判空,执行instance=new Singleton();但是在执行CPU指令的时候,是按照1)-》(3)-》(2)的顺序去执行,当执行完CPU指令(3),但是还没有执行CPU指令(2)的时候,恰好!发生了线程切换,线程切换到了线程B,线程B也执行了这个getInstance方法,但是在第一层判空的时候,instance!=null,于是继续往下执行,去使用了这个没有初始化Singleton对象的instance对象,就会出现空指针异常。这就是有序性问题。 由于线程切换以及CPU只保证CPU指令的原子性,会出现原子性问题。 由于不同CPU的缓存之间不可见,就会出现可见性的问题 由于编译器的优化机制导致的执行重排序,会出现有序性问题
学习笔记 因为IO操作执行慢,我们就发明了多进程。这是怎么回事呢?在使用一个CPU时,当一个进程执行IO操作时,这个进程就会标记自己为休眠状态,此时就会执行任务切换(也可以说进程切换),将CPU的使用权交给其他进程,待IO操作执行完毕之后,操作系统会把这个进程唤醒,这个进程就有机会重新获得CPU使用权了,这就是多进程。多进程使得我们可以一边听歌,一边写代码。 但是,多个进程之间是不共享内存空间的,所以当我们进行任务切换(也可以说进程切换)时,需要切换内存映射地址,成本高。 于是我们又有了多线程,同一个进程的多个线程是共享内存空间的,所以使用线程去做任务,线程进行切换的时候,不需要切换内存映射地址,成本就低,所以我们现在用的基本都是多线程,任务切换也指的是线程切换。 又但是,线程切换回导致原子性的并发问题。我们现在基本上都是使用高级编程语言,它的一句语法可能是由多个CPU指令组成的,比如说count++,它是由三个CPU指令去执行的,CPU指令1:先将count变量从内存加载到缓存再加载到CPU寄存器。CPU指令2:然后寄存器去执行count+1操作。CPU指令3:最后将结果写进内存(也有可能写入缓存,因为缓存机制的存在)。 那么问题来了,我们的原子性是基于CPU指令的,CPU保证的原子操作是CPU指令级别的,也就是说,它只能保证CPU指令1或者指令2或者指令3能够完整的运行。又换句话说,线程切换是可以发生在执行完CPU指令1之后,执行CPU指令2之前的,这就是原子性问题了,它也会导致count加了两次,但是结果为1; 线程A执行指令1,从内存中读取到count=0。此时线程切换到线程B,线程B也读取到count=0,但是完整的执行了三个指令操作,返回给内存count=1。然后线程又切换回A,A继续执行操作,但是它任然是对count=0执行后面两条指令的操作,最后得到的结果也是count=1,返回给内存。但是我们期望的结果是2而不是1;
学习笔记 在单核时代,多个线程在一个CPU上执行,每个线程都是对一个CPU里的这个缓存进行操作,一个线程对这个缓存进行的操作,另一个线程一定是可见的,因为它们操作的是同一片缓存。 一个线程对共享变量的修改,另一个线程能立刻看到,这就是可见性。 但在多核时代,多个线程在多个CPU上执行,每个线程可能操作着不同的CPU里的缓存。CPU去修改变量时,先从内存里加载变量到寄存器里进行操作,然后写进它的缓存里,再找个时机写进内存中。每个CPU都有各自的缓存,不同的线程操作不同的缓存时,看不到另一个线程操作的缓存,这就是可见性问题。 比如说,两个线程同时执行count++的操作,线程A使用CPU-1,线程B使用CPU-2,两个线程同时在内存里加载了count=0到不同的缓存中,然后执行了+1操作,此时两个缓存中的count都是=1,如果此时再同时写进内存,那么count的值就是=1,明明加了两次。 线程A和线程B都在各自CPU的缓存里自己搞自己的,互不可见,如果线程A和线程B都各自在自己的缓存中执行一万次count++的操作,那最后得到的count的结果不是20000,而是10000-20000之间(为什么是10000-20000之间?因为两个线程中,可能有一个线程会从内存里读到的count的值是另一个线程操作了n次后返回给内存的),可是,我明明对count进行20000次的+1操作。这就是可见性问题导致的。


