【同步-专栏系列】2.利用互斥锁解决原子性问题

原子性的定义:一个或多个操作在CPU的执行过程中,不被中断的特性叫做原子性。
我们知道引起原子性问题的原因是“线程切换”。所以如果能够禁止线程切换就解决问题了?而操作系统是依赖CPU中断做线程切换的,那么我们禁用CPU中断不就行了吗?
在单核CPU的环境下,这个答案是可行的。但是现在是多核CPU时代。在多核CPU场景下,假如同一时刻有两个线程在运行,一个线程执行在CPU-1上,一个执行在CPU-2上。那么禁止中断,只能保证线程在CPU上的连续执行,并不能保证同一个时刻只有一个线程在执行。

“同一时刻只有一个线程在执行”,这个条件非常重要,我们称之为“互斥”。如果我们能保证对共享变量的修改是互斥的,那么无论是单核CPU或者是多核CPU就能保证原子性了。
实现互斥,最容易想到就是锁。

1.简易锁模型

【同步-专栏系列】2.利用互斥锁解决原子性问题
我们将一段需要互斥执行的代码称为临界区。在进入临界区之前,首先尝试加锁lock,如果加锁成功进入临界区,我们称这个线程持有了锁。否则就等待持有锁的线程释放锁;持有锁的线程执行完临界区的代码之后,释放锁unLock。
现在还有进一步的问题,我们锁的是什么,保护的又是什么?

2.改进之后的锁模型

【同步-专栏系列】2.利用互斥锁解决原子性问题
解释下上图:我们把临界区要保护的资源标记为R;我们要为受保护资源R创建一把锁LR;针对这把锁LR,我们在进出临界区前后填上加锁和解锁操作。
这是通用的锁模型,要注意的是锁和受保护资源的关联关系。避免出现类似“锁自家门来保护他家资源”的问题。

3.Java语言提供的额锁 synchronized

Java语言提供了synchronized关键字,实现了锁的功能。它是JVM内部的实现的。synchronized关键字可以修改方法和代码块,如下:

public class SynchronizeApp {
    //修饰静态方法
    public static synchronized void fun1(){
        // 临界区
    }

    //修饰成员方法
    public synchronized void fun2(){
        // 临界区
    }

    //修饰代码块
    private Object obj = new Object();
    public synchronized void fun3(){

        synchronized (obj){
            // 临界区
        }
    }
}

对照上面的改进锁模型,你可能发现并没有什么加锁/解锁操作啊。 其实加锁lock和解锁unLock是隐式的。.java文件在编译时,会在方法或者代码块自动加上lock()方法和unlock()方法.。
你可能又有一个问题了,那么加锁和解锁锁定的对象是什么呢?我就直接抛出结论了:
1.修饰非静态方法,锁定的是当前实例this
2.修饰静态方法,锁定的是当前类的Class对象

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

一个合理的关系是:受保护资源和锁之间的关联关系是N:1的关系。一把锁可以保护N个对象,但是不能用N把锁来保护一个对象。我们举这样一个例子来说明:

class App{
 static long value = 0L;
    public synchronized long get(){
        return value;
    }

    public static synchronized void plus(){
        value++;
    }
}

我们用synchronized实现了一个自增操作。plus是+1操作,get是获取操作。
注意,value是静态成员,get和plus都使用了synchronized修饰,但是get是成员方法。这个例子中,受保护的资源时value,get和plus的锁定对象分别是this,App.class。由于临界区get和plus是由两把锁保护的,这两把锁并不存在互斥关系,临界区plus对value修改对get没有可见性保证,这就导致并发问题了。