Java并发编程实战
王宝令
资深架构师
立即订阅
14233 人已学习
课程目录
已完结 50 讲
0/4登录后,你可以任选4讲全文学习。
开篇词 (1讲)
开篇词 | 你为什么需要学习并发编程?
免费
学习攻略 (1讲)
学习攻略 | 如何才能学好并发编程?
第一部分:并发理论基础 (13讲)
01 | 可见性、原子性和有序性问题:并发编程Bug的源头
02 | Java内存模型:看Java如何解决可见性和有序性问题
03 | 互斥锁(上):解决原子性问题
04 | 互斥锁(下):如何用一把锁保护多个资源?
05 | 一不小心就死锁了,怎么办?
06 | 用“等待-通知”机制优化循环等待
07 | 安全性、活跃性以及性能问题
08 | 管程:并发编程的万能钥匙
09 | Java线程(上):Java线程的生命周期
10 | Java线程(中):创建多少线程才是合适的?
11 | Java线程(下):为什么局部变量是线程安全的?
12 | 如何用面向对象思想写好并发程序?
13 | 理论基础模块热点问题答疑
第二部分:并发工具类 (14讲)
14 | Lock和Condition(上):隐藏在并发包中的管程
15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?
16 | Semaphore:如何快速实现一个限流器?
17 | ReadWriteLock:如何快速实现一个完备的缓存?
18 | StampedLock:有没有比读写锁更快的锁?
19 | CountDownLatch和CyclicBarrier:如何让多线程步调一致?
20 | 并发容器:都有哪些“坑”需要我们填?
21 | 原子类:无锁工具类的典范
22 | Executor与线程池:如何创建正确的线程池?
23 | Future:如何用多线程实现最优的“烧水泡茶”程序?
24 | CompletableFuture:异步编程没那么难
25 | CompletionService:如何批量执行异步任务?
26 | Fork/Join:单机版的MapReduce
27 | 并发工具类模块热点问题答疑
第三部分:并发设计模式 (10讲)
28 | Immutability模式:如何利用不变性解决并发问题?
29 | Copy-on-Write模式:不是延时策略的COW
30 | 线程本地存储模式:没有共享,就没有伤害
31 | Guarded Suspension模式:等待唤醒机制的规范实现
32 | Balking模式:再谈线程安全的单例模式
33 | Thread-Per-Message模式:最简单实用的分工方法
34 | Worker Thread模式:如何避免重复创建线程?
35 | 两阶段终止模式:如何优雅地终止线程?
36 | 生产者-消费者模式:用流水线思想提高效率
37 | 设计模式模块热点问题答疑
第四部分:案例分析 (4讲)
38 | 案例分析(一):高性能限流器Guava RateLimiter
39 | 案例分析(二):高性能网络应用框架Netty
40 | 案例分析(三):高性能队列Disruptor
41 | 案例分析(四):高性能数据库连接池HiKariCP
第五部分:其他并发模型 (4讲)
42 | Actor模型:面向对象原生的并发模型
43 | 软件事务内存:借鉴数据库的并发经验
44 | 协程:更轻量级的线程
45 | CSP模型:Golang的主力队员
结束语 (1讲)
结束语 | 十年之后,初心依旧
用户故事 (2讲)
用户来信 | 真好,面试考到这些并发编程,我都答对了!
3 个用户来信 | 打开一个新的并发世界
Java并发编程实战
登录|注册

03 | 互斥锁(上):解决原子性问题

王宝令 2019-03-05

第一篇文章中我们提到,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。理解这个特性有助于你分析并发编程 Bug 出现的原因,例如利用它可以分析出 long 型变量在 32 位机器上读写可能出现的诡异 Bug,明明已经把变量成功写入内存,重新读出来却不是自己写入的。

那原子性问题到底该如何解决呢?

你已经知道,原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?而操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。

在早期单核 CPU 时代,这个方案的确是可行的,而且也有很多应用案例,但是并不适合多核场景。这里我们以 32 位 CPU 上执行 long 型变量的写操作为例来说明这个问题,long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位,如下图所示)。

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。

但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。

© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
该试读文章来自付费专栏《Java并发编程实战》,如需阅读全部文章,
请订阅文章所属专栏。
立即订阅
登录 后留言

精选留言(168)

  • nonohony
    加锁本质就是在锁对象的对象头中写入当前线程id,但是new object每次在内存中都是新对象,所以加锁无效。

    作者回复: synchronized的实现都知道了,厉害!

    2019-03-05
    8
    224
  • w1sl1y
    经过JVM逃逸分析的优化后,这个sync代码直接会被优化掉,所以在运行时该代码块是无锁的

    作者回复: 👍厉害

    2019-03-05
    4
    70
  • zyl
    sync锁的对象monitor指针指向一个ObjectMonitor对象,所有线程加入他的entrylist里面,去cas抢锁,更改state加1拿锁,执行完代码,释放锁state减1,和aqs机制差不多,只是所有线程不阻塞,cas抢锁,没有队列,属于非公平锁。
    wait的时候,线程进waitset休眠,等待notify唤醒

    作者回复: sync的优化都知道了,厉害啊

    2019-03-05
    2
    57
  • 探索无止境
    不能,因为new了,所以不是同一把锁。老师您好,我对那 synchronized的理解是这样,它并不能改变CPU时间片切换的特点,只是当其他线程要访问这个资源时,发现锁还未释放,所以只能在外面等待,不知道理解是否正确

    作者回复: 理解正确!

    2019-03-05
    1
    34
  • 老杨同志
    两把不同的锁,不能保护临界资源。而且这种new出来只在一个地方使用的对象,其它线程不能对它解锁,这个锁会被编译器优化掉。和没有syncronized代码块效果是相同的

    作者回复: 实在是太厉害了!!!

    2019-03-05
    2
    33
  • 王大王
    Get方法加锁不是为了解决原子性问题,这个读操作本身就是原子性的,是为了实现不能线程间addone方法的操作结果对get方法可见,那么value变量加volitile也可以实现同样效果吗?

    作者回复: 是的,并发包里的原子类都是靠它实现的

    2019-03-05
    3
    30
  • sbwei🚴
    最后的思考题: 多把锁保护同一个资源,就像一个厕所坑位,有N多门可以进去,没有丝毫保护效果,管理员一看,还不如把门都撤了,弄成开放式(编译器代码优化)😂。
    2019-03-24
    1
    29
  • 宝爸学学学
    我觉得评论区学到的更多啊,你们真的是来学习的吗 :D
    2019-05-15
    1
    18
  • 石头剪刀布
    老师说:现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的。
    不能用两把锁锁定同一个资源吗?
    如下代码:
    public class X {
        private Object lock1 = new Object();
        private Object lock2 = new Object();
        private int value = 0;

        private void addOne() {
            synchronized (lock1) {
                synchronized (lock2) {
                    value += 1;
                }
            }
        }

        private int get() {
            synchronized (lock1) {
                synchronized (lock2) {
                    return value;
                }
            }
        }
    }

    虽然说这样做没有实际意义,但是也不会导致死锁或者其他不好的结果吧?请老师指导,谢谢。

    作者回复: 你这么优秀,我该怎么指导呢?你这不是用lock1 保护 lock2,lock2保护value吗?很符合我们的原则。我怎么没想到呢?

    2019-03-08
    16
  • 大南瓜
    沙发,并不能,不是同一把锁

    作者回复: 为快点赞

    2019-03-05
    10
  • 小和尚笨南北
    不正确
    使用锁保护资源时,对资源的所有操作应该使用同一个锁,这样才能起到保护的作用。
    课后题中每个线程对资源的操作都是用的是各自的锁,不存在互斥和竞争的情况。
    这就相当于有一个房间,每个人过来都安装一个门,每个人都有自己门的钥匙,大家都可以随意出入这个房间。
    由于每个线程都可以随时进入方法,所以存在原子性问题;
    但是因为每次都有加锁和解锁的操作,unlock操作会使其他缓存的变量失效,需要重新从主内存中加载变量的值,所以可以解决可见性问题。
    如有错误,请老师指正。

    作者回复: 比喻很生动

    2019-03-05
    8
  • 别皱眉
    相信很多人跟我一样会碰到这个问题,评论里也看到有人在问,内容有点长,辛苦老师帮忙大家分析下了 哈哈
      ---------------------------------------------------------
    public class A implements Runnable {
        public Integer b = 1;
     
        @Override
        public void run() {
           System.out.println("A is begin!");
           while (true) {
                   System.out.println("a");
               // System.out.println(b);
               if (b.equals(2))
                  break;
           }
     
           System.out.println("A is finish!");
        }
     
        public static void main(String[] args) {
           A a = new A();
           //线程A
           new Thread(a).start();
           try {
               Thread.sleep(1000);
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
           a.b = 2;
        }
    }
     
    我们知道这个程序会出现可见性问题。
    但是在while内加上System.out.println(b)后 当主线程修改b的值后 线程A居然能够取得最新值 可见性问题得到解决
    System.out.println(b)的实现如下
        public void println(String x) {
            synchronized (this) {
                print(x);
                newLine();
            }
        }
     
    Doug Lea大神的Concurrent Programming in Java一书中有这样一个片段来描述synchronized这个关键字:
     
    这里英文就不放出来了 字数超过两千……
    这篇文章也有提及https://www.jianshu.com/p/3c06ffbf0d52
     
    简单翻译一下:从本质上来说,当线程释放一个锁时会强制性的将工作内存中之前所有的写操作都刷新到主内存中去,而获取一个锁则会强制性的加载可访问到的值到线程工作内存中来。虽然锁操作只对同步方法和同步代码块这一块起到作用,但是影响的却是线程执行操作所使用的所有字段。


    也就是说当调用System.out.println("a")时当前线程的缓存会被重新刷新过,所以才能够读到这个值最新值
     ---------------------------------------------------------
    然后问题来了
    问题1:
    首先上面的说法不知道是不是真的是这样。
    然后我在下面加了System.out.println(b) 结果打印出来的是旧值,但是下面的b.equals(2)却能通过 这里没弄明白 我觉得应该是编译器进行了优化?因为现在大三能力不够,还没学会看class文件 没法验证
     
    问题2:
    网上找了一些文章
    有些人的说法是:打印是IO操作,而IO操作会引起线程的切换,线程切换会导致线程原本的缓存失效,从而也会读取到修改后的值。
     
    我尝试着将打印换成File file = new File("D://1.txt");这句代码,程序也能够正常的结束。当然,在这里也可以尝试将将打印替换成synchronized(A.class){ }这句空同步代码块,发现程序也能够正常结束。
     
    这里有个问题就是 线程切换时会把之前操作的相关数据保存到内存里,切换回来后会把内存里的数据重新加载到寄存器里吗,这样说的话 就算切换也是获取不到修改后的值的,不知道是什么做到能够读到这个修改后的值的?
     
    问题3:
    是不是
    线程执行过程中,操作系统会随机性的把缓存刷到内存
    线程结束后一定会把缓存里的数据刷到内存

     ---------------------------------------------------------
    在评论里好多大神 能学到好多东西😄😄

    作者回复: 1. println的代码里锁的this指的是你的控制台,这个锁跟你的代码没关系,而且println里也没有写操作,所以println不会导致强刷缓存。

    我觉得是因为println产生了IO,IO相对CPU来说,太慢,所以这个期间大概率的会把缓存的值写入内存。也有可能这个线程被调度到了其他的CPU上,压根没有缓存,所以只能从内存取数。你调用sleep,效果应该也差不多。

    2. 线程切换显然不足以保证可见性,保证的可见性只能靠hb规则。

    3. 线程结束后,不一定会强刷缓存。否则Join的规则就没必要了

    并发问题本来就是小概率的事件,尤其有了IO操作之后,概率就更低了。

    2019-03-17
    2
    7
  • 陈华
    我理解get方法不需要加synchroized关键字,也可以保证可见性。
    因为 对 value的写有被 synchroized 修饰,addOne()方法结束后,会强制其他CPU缓存失效,从新从内存读取最新值!

    class SafeCalc {
      long value = 0L;
      long get() {
        return value;
      }
      synchronized void addOne() {
        value += 1;
      }
    }

    作者回复: 你说的对,从实现上看是这样。但是hb没有这样的要求

    2019-03-07
    3
    5
  • churchchen
    class SafeCalc {
      static long value = 0L;
      synchronized long get() {
        return value;
      }
      synchronized static void addOne() {
        value += 1;
      }
    }


    get()方法的可见性不太理解为什么不能保证

    作者回复: get和addone锁的是一个对象,结合上一期的hb规则再想想

    2019-03-06
    5
  • 彻头撤尾
    别皱眉同学,我特意把你的代码考下来了,run方法里什么都不加 就是死循环,加一个变量b==2作为循环出口,线程b也可以正常退出的啊!!!!线程可见性问题应该描述的是变量被修改的这一瞬间其他线程可见性问题吧?你加不加打印语句,加不加同步代码块都不会影响线程b的正常结束吧?只要变量最新值刷到主内存中,线程b 就可见然后就终止了.

    作者回复: 感谢热心同学的回复!!

    2019-03-31
    1
    4
  • 别皱眉
    老师,我觉得get方法有必要用加锁来保证可见性的另一个理由如下:
    class SafeCalc {
    long value = 0L;

    synchronized long get() {
    return value;
    }

    synchronized void add(int i) {
    // 业务代码....假如这里比较耗时
    value += i;
    }
    }

    假如线程A执行add方法 当方法还没执行完
    线程B执行get方法
    如果get方法没有加锁 因为此时A正在修改这个数据 B获取的数据不是最新的

    您看我说的对吗?还是说具体场景有不同的需求,有些还是允许这点延迟的?
    本人大三,请前辈多指教😁😁谢谢

    作者回复: 我觉得你这个才是正道,并发问题小心还躲不过呢,哪里敢冒险啊!没想到还有学生看这个专栏,有前途👍

    2019-03-13
    4
  • 落落彩虹
    老师的文章我都要看几遍.评论区也不敢放过.

    评论区有些demo,注意关于join的hb原则;注意system.out.println对可见性的影响,该方法内部加锁了.
    还有个问题,如果我不用join,而是sleep足够长时间以确保线程跑完了,也能保证可见性.因为线程结束了他的本地工作空间该释放了,数据要强制刷回内存了……这也可以认为是join的hb原则吗?我看网上说到hb都会把join的那个原则说成是线程终止规则.

    作者回复: 感谢不离不弃啊
    测试的时候经常用sleep,实际项目还是用join吧。这个我感觉不能认为是join原则。规范里确实没有。

    2019-03-10
    4
  • 空白
    在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

    如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除。
    2019-06-28
    3
  • 老焦
    有同学说get方法不用sync也能保证可见性,这是对的。但如果真的这么做了,原子性就可能会被打破。sync并不保证线程不被中断。如果在写高低两个双字的中间写线程被中断,而读线程被调度执行,因为读没有尝试加锁,所以可以读到写了一半的结果。这种情况都不用考虑多核,单核都会出现原子性问题。所以谨慎起见还是给get加上sync保险点。

    作者回复: 👍

    2019-06-20
    3
  • 侯大虎
    老师,有个小问题 class锁锁的是该类的所有实例,和this不应该是同一把锁吗(this不就是这个类的实例吗)?

    作者回复: 没有包含关系,就像公交卡和单次票一样,都能坐车

    2019-03-30
    2
    3
收起评论
99+
返回
顶部