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

 

 

 

 

 


极客时间:Java并发编程实战 03互斥锁(上)
极客时间:Java并发编程实战 04互斥锁(下)

 

 

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并发编程实战 03互斥锁 解决原子性问题

 

文章系列#

Java并发编程实战 01并发编程的Bug源头
Java并发编程实战 02Java如何解决可见性和有序性问题

摘要#

在上一篇文章02Java如何解决可见性和有序性问题当中,我们解决了可见性和有序性的问题,那么还有一个原子性问题咱们还没解决。在第一篇文章01并发编程的Bug源头当中,讲到了把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性,那么原子性的问题该如何解决。

同一时刻只有一个线程执行这个条件非常重要,我们称为互斥,如果能保护对共享变量的修改时互斥的,那么就能保住原子性。

简易锁#

我们把一段需要互斥执行的代码称为临界区,线程进入临界区之前,首先尝试获取加锁,若加锁成功则可以进入临界区执行代码,否则就等待,直到持有锁的线程执行了解锁unlock()操作。如下图:
互斥锁1.jpg

但是有两个点要我们理解清楚:我们的锁是什么?要保护的又是什么?

改进后的锁模型#

在并发编程世界中,锁和锁要保护的资源是有对应关系的。
首先我们需要把临界区要保护的资源R标记出来,然后需要创建一把该资源的锁LR,最后针对这把锁,我们需要在进出临界区时添加加锁lock(LR)操作和解锁unlock(LR)操作。如下:
互斥锁2.jpg

Java语言提供的锁技术:synchronized#

synchronized可修饰方法和代码块。加锁lock()和解锁unlock()都会在synchronized修饰的方法或代码块前后自动加上加锁lock()和解锁unlock()操作。这样做的好处就是加锁和解锁操作会成对出现,毕竟忘了执行解锁unlock()操作可是会让其他线程死等下去。
那我们怎么去锁住需要保护的资源呢?在下面的代码中,add1()非静态方法锁定的是this对象(当前实例对象),add2()静态方法锁定的是X.class(当前类的Class对象)

Copy
public class X {
    public synchronized void add1() {
        // 临界区
    }
    public synchronized static void add2() {
        // 临界区
    }
}

上面的代码可以理解为这样:

Copy
public class X {
    public synchronized(this) void add() {
        // 临界区
    }
    public synchronized(X.class) static void add2() {
        // 临界区
    }
}

使用synchronized 解决 count += 1 问题#

01 并发编程的Bug源头文章当中,我们提到过count += 1 存在的并发问题,现在我们尝试使用synchronized解决该问题。

Copy
public class Calc {
    private int value = 0;
    public synchronized int get() {
        return value;
    }
    public synchronized void addOne() {
        value += 1;
    }
}

addOne()方法被synchronized修饰后,只有一个线程能执行,所以一定能保证原子性,那么可见性问题呢?在上一篇文章02 Java如何解决可见性和有序性问题当中,提到了管程中的锁规则,一个锁的解锁 Happens-Before 于后续对这个锁的加锁。管程,在这里就是synchronized(管程的在后续的文章中介绍)。根据这个规则,前一个线程执行了value += 1操作是对后续线程可见的。而查看get()方法也必须加上synchronized修饰,否则也没法保证其可见性。
上面这个例子如下图:
互斥锁3.jpg

那么可以使用多个锁保护一个资源吗,修改一下上面的例子后,get()方法使用this对象锁来保护资源valueaddOne()方法使用Calc.class类对象来保护资源value,代码如下:

Copy
public class Calc {
    private static int value = 0;
    public synchronized int get() {
        return value;
    }
    public static synchronized void addOne() {
        value += 1;
    }
}

上面的例子用图来表示:
互斥锁4.jpg

在这个例子当中,get()方法使用的是this锁,addOne()方法使用的是Calc.class锁,因此这两个临界区(方法)并没有互斥性,addOne()方法的修改对get()方法是不可见的,所以就会导致并发问题。
结论:不可使用多把锁保护一个资源,但能使用一把锁保护多个资源(这里没写例子,只写了一把锁保护一个资源)

保护没有关联关系的多个资源#

在银行的业务当中,修改密码和取款是两个再经常不过的操作了,修改密码操作和取款操作是没有关联关系的,没有关联关系的资源我们可以使用不同的互斥锁来解决并发问题。代码如下:

Copy
public class Account {
    // 保护密码的锁
    private final Object pwLock = new Object();
    // 密码
    private String password;

    // 保护余额的锁
    private final Object moneyLock = new Object();
    // 余额
    private Long money;

    public void updatePassword(String password) {
        synchronized (pwLock) {
            // 修改密码
        }
    }

    public void withdrawals(Long money) {
        synchronized (moneyLock) {
            // 取款
        }
    }
}

分别使用pwLockmoneyLock来保护密码和余额,这样修改密码和修改余额就可以并行了。使用不同的锁对受保护的资源进行进行更细化管理,能够提升性能,这种锁叫做细粒度锁。
在这个例子当中,你可能发现我使用了final Object来当成一把锁,这里解释一下:使用锁必须是不可变对象,若把可变对象作为锁,当可变对象被修改时相当于换锁,而且使用LongInteger作为锁时,在-128到127之间时,会使用缓存,详情可查看他们的valueOf()方法。

保护有关联关系的多个资源#

在银行业务当中,除了修改密码和取款的操作比较多之外,还有一个操作比较多的功能就是转账。账户 A 转账给 账户B 100元,账户A的余额减少100元,账户B的余额增加100元,那么这两个账户就是有关联关系的。在没有理解互斥锁之前,写出的代码可能如下:

Copy
public class Account {
    // 余额
    private Long money;
    public synchronized void transfer(Account target, Long money) {
        this.money -= money;
        if (this.money < 0) {
            // throw exception
        }
        target.money += money;
    }
}

在转账transfer方法当中,锁定的是this对象(用户A),那么这里的目标用户target(用户B)的能被锁定吗?当然不能。这两个对象是没有关联关系的。正确的操作应该是获取this锁和target锁才能去进行转账操作,正确的代码如下:

Copy
public class Account {
    // 余额
    private Long money;
    public synchronized void transfer(Account target, Long money) {
        synchronized(this) {
            synchronized (target) {
                this.money -= money;
                if (this.money < 0) {
                    // throw exception
                }
                target.money += money;
            }
        }
    }
}

在这个例子当中,我们需要清晰的明白要保护的资源是什么,只要我们的锁能覆盖所有受保护的资源就可以了
但是你以为这个例子很完美?那就错了,这里面很有可能会发生死锁。你看出来了吗?下一篇文章我就用这个例子来聊聊死锁。

总结#

使用互斥锁最最重要的是:我们的锁是什么?锁要保护的资源是什么?,要理清楚这两点就好下手了。而且锁必须为不可变对象。使用不同的锁保护不同的资源,可以细化管理,提升性能,称为细粒度锁

参考文章:
极客时间:Java并发编程实战 03互斥锁(上)
极客时间:Java并发编程实战 04互斥锁(下)

04 | 互斥锁(下):如何用一把锁保护多个资源?

王宝令 2019-03-07
 
在上一篇文章中,我们提到受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源,并且结合文中示例,我们也重点强调了“不能用多把锁来保护一个资源”这个问题。而至于如何保护多个资源,我们今天就来聊聊。
当我们要保护多个资源时,首先要区分这些资源是否存在关联关系。

保护没有关联关系的多个资源

在现实世界里,球场的座位和电影院的座位就是没有关联关系的,这种场景非常容易解决,那就是球赛有球赛的门票,电影院有电影院的门票,各自管理各自的。
同样这对应到编程领域,也很容易解决。例如,银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
相关的示例代码如下,账户类 Account 有两个成员变量,分别是账户余额 balance 和账户密码 password。取款 withdraw() 和查看余额 getBalance() 操作会访问账户余额 balance,我们创建一个 final 对象 balLock 作为锁(类比球赛门票);而更改密码 updatePassword() 和查看密码 getPassword() 操作会修改账户密码 password,我们创建一个 final 对象 pwLock 作为锁(类比电影票)。不同的资源用不同的锁保护,各自管各自的,很简单。
 
 
 
 
 
 
 
 
 
  • 老师说:现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的。
    不能用两把锁锁定同一个资源吗?
    如下代码:
    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
    6
    31
  • 别皱眉
    相信很多人跟我一样会碰到这个问题,评论里也看到有人在问,内容有点长,辛苦老师帮忙大家分析下了 哈哈
      ---------------------------------------------------------
    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
    18

  • 大南瓜
    沙发,并不能,不是同一把锁

posted on 2020-05-07 08:53  shuzihua  阅读(270)  评论(0编辑  收藏  举报

导航