锁是如何保护并发安全性的?

146 阅读5分钟

我正在参与掘金创作者训练营第5期,点击了解活动详情

1、多线程环境下的并发安全性

多线程环境下,存在这样一种共享变量,它们在多个线程中都可以访问。由于可能有多个线程会几乎同时对变量进行操作,因此很容易出现并发安全性问题。

举个栗子~

在单线程下,我们对一个数进行10000次自增操作,如下:

public class Add {
    public static void main(String[] args) {
        System.out.println(new UnsafeAdd().add());
    }

    static class UnsafeAdd {
        private int count=0;
        public int add() {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
            return count;
        }
    }
}

运行结果:

image.png

而在多线程下,同样的操作可能会出现意料之外的结果:

public class Add {
    public static void main(String[] args) {
        SafeAdd safeAdd = new SafeAdd();
        System.out.println(safeAdd.add());
    }

    static class SafeAdd {
        private int count=0;

        public int add() {
            for (int i = 0; i < 1000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int j = 0; j < 10; j++) {
                            count++;
                        }
                    }
                }).start();
            }
            try {
                Thread.sleep(100);//睡眠100ms等待子线程执行完毕
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return count;
        }
    }
}

运行结果(需要多运行几次,不一定每次都会出错):

image.png

2、异常结果出现的原因

为什么会出现上面的异常情况呢?

事实上,count++的计算过程可以分为三步

  • 从内存中读取count
  • count=count+1
  • 将count写入内存

在单线程下,这样的步骤并不会发生什么错误,但是放到多线程下,就不一定了。考虑下面一种情景:

  1. 在某一线程A中,该线程先读取了count的值,如count=1
  2. 而此时有另外一个线程B刚刚修改完count,将count改成了2
  3. 线程A对count进行count=count+1的操作,此时线程A中count的值为2
  4. 线程A将count写入内存,此时count的值为2
graph TD
1.线程A读取count值为1 --> 2.线程B将内存中count值改为2
2.线程B将内存中count值改为2 --> 3.线程A进行count=count+1操作
3.线程A进行count=count+1操作 --> 4.线程A将count写入内存

1.内存中count值为1-->2.内存中count值为2
2.内存中count值为2-->3.内存中count值为2
3.内存中count值为2-->4.内存中count值为2

发现了吗?尽管进行了两次count自增的操作,但是从运行结果来看只发生了一次。

而这样的结果在很多环境下是不可接受的,即使是一个数字的偏差也可能导致巨大的损失。

3、通过锁来避免同时对共享对象的同时操作

依旧是上面的多线程下将count自增10000次的代码,我们只需要用 synchronized代码块将count++包裹起来,再进行测试会发现,再也没有出现count最终值不等于10000的情况了。

而使用synchronized关键字即为我们通常说的加锁。(除此之外还有其他的加锁方式,这里不多作展开)

public class Add {
    public static void main(String[] args) {
        SafeAdd safeAdd = new SafeAdd();
        System.out.println(safeAdd.add());
    }

    static class SafeAdd {
        private int count=0;

        public int add() {
            for (int i = 0; i < 1000; i++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int j = 0; j < 10; j++) {
                            synchronized (this){//这里进行修改
                                count++;
                            }
                        }
                    }
                }).start();
            }
            try {
                Thread.sleep(100);//睡眠100ms等待子线程执行完毕
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return count;
        }
    }
}

4、锁是怎么实现保护线程安全性的

4.1、理解锁的工作流程

单单讲通过synchronized显然是过于浅显了,再深入展开下锁的实现原理。

synchronized (this)
{    //获得钥匙,操作宝箱
     count++;
}    //操作完毕,归还钥匙
  • 我们将被上锁的代码片段想象成一个宝箱,要获得钥匙才可以对宝箱内部进行操作。

  • 而各个线程在并发执行到加锁片段时需要争夺那根钥匙,才能操作宝箱。

  • 在操作完之后,要将钥匙归还,以供其他线程使用(争夺)。

4.2、锁的工作原理

  • 在上述代码中,this便起到了钥匙的作用,事实上,可以当作钥匙的对象有许多,只需要满足以下条件即可:
    • 钥匙可以是任何一个类的对象。

    • 这个对象在启动的各个多线程内都可以访问。

    • 之所以任何对象都可以被当作钥匙的原因是在jvm中,在每个对象的头部会维护一个被称为mark world的片段,在其中有着一个指向锁的指针,因此将对象当场钥匙,实质上用到的是其mark world中指向的锁,下面我们不再称之为钥匙。

    • 在mark world中指向的锁中,维护了一个计数器(初始为0)

      • 当进入被加锁片段时,会调用monitorenter指令,将计数器值加一,此时如果其他线程想使用锁,发现计数器值不为0,便不法获得锁。

      • 当退出被加锁片段时,会调用monitorexit指令,将计数器值减一,当计数器值为0时,别的线程就可以获得锁了。

      • 计数器的值并不只有0和1,详情可以查阅可重入锁的概念

在上述的过程中,不难看出与不加锁条件下的区别:

  • 不加锁时:多个线程可以同时操作count,因此容易带来误差
  • 加锁后:在同一时刻,因为只有一把钥匙,也只有一个线程可以获取钥匙,至多只有一个线程能够操作count

5、小结

在本文中,我们从分析为什么会出现并发安全性问题出发,引入了锁的概念,并且从底层原理出发,对synchronized关键字是如何保护并发安全作了较为简单的解释。