Java并发基础—3、互斥锁:解决原子性问题

一、互斥锁

互斥锁的目的:解决原子性问题,即“资源在同一时刻只能被一个线程占有”;根本方法就是禁止线程切换(单核场景)或者同一时刻线程互斥(多核场景)
临界区:            需要互斥执行的代码称为“临界区”,进入/离开临界区,需要加锁/解锁操作。
锁的对象:        即需要被放入临界区的对象,主要逻辑如下:

注意:

        锁住的对象必须明确范围: 如果范围太大可能导致锁不生效(直接被JVM逃逸分析优化掉了),或者“强行串行”导致性能下降 如果范围太小也可能导致锁不生效,或死锁 锁住的对象不正确,可能导致锁住了其他对象

二、synchronized关键字

        锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子

class X {
  // 修饰非静态方法,锁对象是this
  //相当于synchronized(this) static void bar() {}  
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法,锁对象是X.class(因为static方法属于类)
  //相当于synchronized(X.class) static void bar() {}
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块,锁对象是obj(obj也可以是this,也可以是X.class)
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  

        看完之后你可能会觉得有点奇怪,这个和我们上面提到的模型有点对不上号啊,加锁 lock() 和解锁 unlock() 在哪里呢?其实这两个操作都是有的,只是这两个操作是被 Java 默默加上的,Java 编译器会在 synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock(),这样做的好处就是加锁 lock() 和解锁 unlock() 一定是成对出现的,毕竟忘记解锁 unlock() 可是个致命的 Bug(意味着其他线程只能死等下去了)。

优势:使用简单,加锁/解锁有JVM成对提供,可以防止忘记解锁(可能会导致其他线程持续等待)
劣势:非公平锁,使用不当可能导致线程永远无法被激活。

        那 synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象在哪里呢?上面的代码我们看到只有修饰代码块的时候,锁定了一个 obj 对象,那修饰方法的时候锁定的是什么呢?这个也是 Java 的一条隐式规则:

        当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;

class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}

        当修饰非静态方法的时候,锁定的是当前实例对象 this。

class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}

2.1 用synchronized解决count+=1的问题  

        SafeCalc 这个类有两个方法:一个是 get() 方法,用来获得 value 的值;另一个是 addOne() 方法,用来给 value 加 1,并且 addOne() 方法我们用 synchronized 修饰。

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

        我们先来看看 addOne() 方法,首先可以肯定,被 synchronized 修饰后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作,那是否有可见性问题呢?要回答这问题,就要重温一下上一篇文章中提到的管程中锁的规则

        管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

        我们知道 synchronized 修饰的临界区是互斥的,也就是说同一时刻只有一个线程执行临界区的代码;而所谓“对一个锁解锁  Happens-Before 后续对这个锁的加锁”,指的是前一个线程的解锁操作对后一个线程的加锁操作可见,综合 Happens-Before 的传递性原则,我们就能得出前一个线程在临界区修改的共享变量(该操作在解锁之前),对后续进入临界区(该操作在加锁之后)的线程是可见的。

        执行 addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。那如何解决呢?很简单,就是 get() 方法也 synchronized 一下,完整的代码如下所示。当然,实际上这种优化是低效的,两个方法都被synchronized修饰,实际上锁住的就是this这个对象。另外读操作和写操作被同时“串行化了”,也就是说“读时不能写,写时不能读”,这样在多线程情况下,一个线程A在对共享变量写的时候,所有的读线程均会被阻塞,性能下降。

class SafeCalc {
  static long value = 0L;
  synchronized long get() {      //锁住的是this
    return value;
  }
  synchronized void addOne() {   //锁住的是this
    value += 1;
  }
}

           以上示例中的get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。线程模型如下图所示:

         思考以下实现,是否能够保证可见性和原子性:

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

点评:

1、以上实现方式,get和addOne操作均会new出一个obj对象作为锁,但是两个obj并不是同一个
        加锁进入的不是同一个对象的临界区,无法起到原子性的作用。
2、加锁的本质:在对象头中加入当前线程的ID信息,标记被那个线程持有
        解锁操作:当线程释放一个锁时会强制性的将工作内存中之前所有的写操作都刷新到主内存中去,而获取一个锁则会强制性的加载可访问到的值到线程工作内存中来。
        虽然锁操作只对同步方法和同步代码块这一块起到作用,但是影响的却是线程执行操作所使用的所有字段。
3、Java的对象头和对象组成详解
4、JVM逃逸分析会把这个synchronized去除(锁消除)  
        锁消除:如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。
         有一个老哥的解释很生动:“多把锁保护同一个资源,就像一个厕所坑位,有N多门可以进去,没有丝毫保护效果,管理员一看,还不如把门都撤了,弄成开放式(编译器代码优化)”
5、JVM 锁消除/锁粗化/锁升级/锁降级        

 三、锁和受保护资源的关系

        受保护资源和锁之间的关联关系非常重要,一个合理的关系是:受保护资源和锁之间的关联关系是 N:1 的关系,首先见改动的如下的代码:

class SafeCalc {
  static long value = 0L;
  synchronized long get() {             //锁住的是this
    return value;
  }
  synchronized static void addOne() {   //锁住的是SafeCalc.class
    value += 1;
  }
}

        改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。我们可以用下面这幅图来形象描述这个关系。由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

        

3.1 保护没有关联的资源

    银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题。
    到受保护资源和锁之间合理的关联关系应该是 N:1 的关系,即用一把锁来保护多个资源
    实际场景中,这多个不同的资源之间有时候是没有关系的,处理场景有以下几种:
        1、当然为了符合 “N:1” 的原则,可以通过持有this这一把锁来实现(所有的非static方法上用synchronized修饰)
        但是因为查看密码和查看余额 这两个操作之间没有必然关系(实际还得结合具体业务场景),这样的做法实际上将这两类操作统统锁死了,强行变成串行化,实际运行过程中可能会影响性能
        2、所以采用多个锁来保护各自的关心自资源:com.slek.app.springbootartifact.cocurrence.pojo.Account
        用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫"细粒度锁"。示例中模板代码如下:

class Account {
    private final Object balLock = new Object();        // 锁1:保护账户余额

    private Integer balance;        // 账户余额

    private final Object pwLock = new Object();        // 锁2:保护账户密码

    private String password;        // 账户密码

    void withdraw(Integer amt) {        // 取款
        synchronized (this.balLock) {
            if (this.balance > amt) {
                this.balance -= amt;
            }
        }
    }

    Integer getBalance() {              // 查看余额
        synchronized (this.balLock) {
            return balance;
        }
    }


    void updatePassword(String pw) {    // 更改密码
        synchronized (this.pwLock) {
            this.password = pw;
        }
    }

    String getPassword() {              // 查看密码
        synchronized (this.pwLock) {
            return password;
        }
    }
}

3.2 保护有关联的资源

        如果受保护的各个资源之间有关系,处理场景略复杂,具体场景可见下:
        例如两个不同账户AccountA和AccountB之间的转账操作:从AccountA中取出100元,再向AccountB中转入100元。
        AccountA的转出和AccountB的转入是一个原子操作,两者的余额balance需要被同一把锁来保护;否则多个账户同一时刻向同一个账户B转账,读取并写入到了相同的账户B余额balance,那转账的结果就会错误。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值