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

目录

1. 原子性问题该如何解决

2. 简易锁模型

3. 改进后的锁模型

4. Java 提供的锁技术:Synchronized

6. 锁和受保护资源的关系

7. 总结

8. 思考


一个或多个操作在CPU执行的过程中不被中断的特性,称为“原子性“。

1. 原子性问题该如何解决

我们知道,原子性问题的源头是线程切换,如果能够禁用线程切换那不就能解决这个问题了吗?

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换

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

在同一时刻只有一个线程执行,我们称为互斥如果我们能保证对共享变量的修改是互斥的,那么,无论是单核CPU还是多核CPU,就都能保证原子性了

2. 简易锁模型

谈到互斥,相信我们都能想到那个杀手级解决方案:锁。同时大脑中还会出现以下模型:

我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁lock(),如果成功,则进入临界区,此时我们称这个线程所有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁unlock()。

3. 改进后的锁模型

我们应该知道,锁和锁要保护的资源是有对应关系的,比如你用你家的锁保护你家的东西。在并发编程的世界里,锁和资源也应该有这个关系

首先,我们要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源R;其次,我们要保护资源R就得为它创建一把锁LR;最后针对这个锁LR,我们还需要在进出临界区时添上加锁操作和解锁操作。另外,在锁LR和受保护资源之间,特意用一条线做了关联,这个关联非常重要,很多并发Bug的出现都是因为把它忽略了,然后就出现了类似锁自家们来保护他家资产的事情。

4. Java 提供的锁技术:Synchronized

Java提供的 Synchronized 关键字,就是锁的一种实现。Synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的基本使用都是这样的:

class X {
    // 修饰非静态方法
    synchronized void foo() {
    // 临界区
    }
    // 修饰静态方法
    synchronized static void bar() {
    // 临界区
    }
    // 修饰代码块
    Object obj = new Object();
    void baz() {
        synchronized(obj) {
        // 临界区
        }
    }
} 

上面的代码我们看到只有修饰代码块的时候,锁定了一个 object 对象,那修饰方法的时候锁定的是上面呢?这个也是 Java 的一条隐式规则:

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

对于上面的例子,Synchronized 修饰静态方法相当于:

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

修饰非静态方法,相当于:

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

用 Synchronized 解决 count += 1 问题

前面我们提到过 count += 1 存在并发问题,现在我们可以用 Synchronized 来小试牛刀,代码如下:

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

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

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

因此,按照这个规则,如果多个线程同时执行该方法,可见性是可以保证的。但是,也许你忽视了 get() 方法,执行addOne() 方法后,value 的值对 get() 方法是可见的吗?这个可见性是没法保证的。管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性是没法保证的,那么如何解决呢,很简单,就是 get() 方法也用 Synchronized 一下。

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

上面的代码转换为我们提到的锁模型,就是下面图的样子。get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获取 this 这把锁,这样 get() 和 addOne() 也是互斥的。

6. 锁和受保护资源的关系

我们前面提到,受保护资源和锁之间的关联关系非常重要,一个合理的关系是:受保护资源和锁之间的关联关系是N:1 的关系。还拿球赛门票的管理来类比,就是一个座位,我们只能用一张票来保护,如果多发了重复的票,那就要打架了。现实世界里,我们可以用多把锁来保护同一个资源,但是在并发领域是不行的,不过可以用同一把锁来保护多个资源,类似与包场了。

上面的例子稍作改动,把 value 改成静态变量,把 addOne() 方法改成静态方法,此时 get() 和 addOne() 是否存在并发问题呢

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

仔细观察,就是发现改动后的代码用两个锁保护一个资源,这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区是没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

7. 总结

互斥锁,在并发领域的知名度极高,只要有并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区的代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似与球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。

上面我们提到了几个名词,这里我们在来加深一下:

1. 互斥:在同一时刻只有一个线程执行;

2. 临界区:一段需要互斥执行的代码。

8. 思考

下面的代码用 Synchronized 修饰代码块来尝试解决并发问题,这种方式正确么?有哪些问题?能解决可见性和原子性么?

class SafeCalc {
    long value = 0L;
    long get() {
        synchronized (new Object()) {
            return value;
        }
    }
    void addOne() {
        synchronized (new Object()) {
        value += 1;
        }
    }
}
  • 两把不同的锁,不能保护临街资源,而且这种 new 出来只在一个地方使用的对象,其他线程不能对他解锁,这个锁会被编译器优化掉,和没有使用 synchronized 代码效果是相同的。
  • 加锁本质就是在锁对象的对象头中写入当前线程ID,但是 new object 每次在内存中都是新对象,所以加锁无效。

猜你喜欢

转载自blog.csdn.net/lss446937072/article/details/112690882