线程安全问题(一)——锁的简单使用

线程安全问题的引入

在前面的博文中,我们了解到通过Thread.join()的方法让线程进入等待,能够在一定程度上解决线程抢占式执行的问题。回忆点这里
那么由于多线程代码而导致的bug,这样的问题就是线程安全问题。

案例引入

在下面的代码中,我希望执行之后得到count=100000的结果。

private static long count;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count:"+count);
    }

结果如图:通过结果,我们每次运行得到的答案都是不一样的。
在这里插入图片描述

多线程指令排序问题

在上面的demo中,执行count++的指令并不只有一条这么简单。它可以分为三个步骤:(1) load操作: 读取内存中count的数值,保存到cpu寄存器中。 (2) add操作: 将寄存器中的count+1 (3) save操作:将寄存器中count的数值存放回内存中。
看似三条指令的简单操作,在多线程并发执行中却容易导致许多问题。
在多线程随即调度的执行状况下,两个线程的指令执行相对顺序可能就会存在多种可能,下面我列出几种可能性。
在这里插入图片描述
在上面的顺序中,我们可以理解为 在第一次t1线程的count++之后,count=1的值存放于寄存器中,接下来t2线程count++的时候,load指令下读取的数值为count=0,之后save操作后count=1,最后执行t1线程的save后值不变。因此执行了两次count++操作后,count的值只加了一次,出现了覆盖现象
我们可以不断扩大到其他状况,如t1线程辛辛苦苦加了100次,但是t2线程最终存放count=1将值覆盖了。

线程不安全的原因

  • 线程在系统中是随即调度,抢占式执行的。
  • 多个线程同时修改同一个变量(参考上述例子)
  • 线程对变量进行修改操作,非原子指令
  • 内存可见性问题
  • 指令重排序问题

解决线程不安全的方法

对于原因2,我们可以通过join等方式防止这种情况的发生,但这样做并不普适,属于少有的情况。

锁的引入

对于案例中的count++操作,我们清楚它不是一个原子操作,因此,程序猿想出来了一个办法:将上述的一系列“非原子”操作打包成一个“原子”操作。这样就能够避免线程不安全的问题。
基于这样的背景,锁被创建出来了。

上锁和解锁过程

假设存在两个线程t1和t2,
(1)上锁:我们首先给t1加上锁(lock),t2也尝试加同一把锁,那么这时候t2线程就会阻塞等待,在Java中该线程处于Blocked状态。
(2)解锁:当线程t1执行完锁住的部分后,线程t1解锁,接着由线程t2通过锁竞争拿到该锁(lock),加锁成功,t2线程转变为Runnable状态。
通过锁的存在,使得线程之间存在互斥的关系。在两个线程之间尚且都要通过锁竞争,而存在多个线程的情况下自然也要通过竞争的方式占据锁。这里必须要明确一个条件:线程之间竞争的必须要是同一把锁

一个简单的锁Demo

通过下面的代码块进行简单的解释。

  • 首先new一个Object类的对象object1.我们要把这个对象作为锁。看到这里我们就可以清楚,锁不必是某种特定的类,他只是一个标识,只是一个对象即可。
  • 在这段案例中,存在main、t1、t2三个线程,main线程十分简单,通过Thread.join()的方法等待t1和t2两个线程运行结束之后打印出结果即可。
  • 在线程t1和t2中,在for循环中,我们可以看到synchronized (object1)。其中synchronized就是锁的关键字。在t1和t2都使用了这段语句,即在进行count++操作之前,需要进行锁竞争,只有拥有锁的一方才可以进行count++操作。
  • 在这段代码块中,t1和t2在for循环的时候是并行执行的,而在锁竞争的时候是串行执行的。这样计算下来比单线程所花费的时间要少许多。
private static int count;
public static void main(String[] args) throws InterruptedException {
        Object object1 = new Object();
        Thread t1 = new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    synchronized (object1) {
                        count++;
                    }
                }
        });
        Thread t2 = new Thread(()->{
                for (int i = 0; i < 50000; i++) {
                    synchronized (object1) {
                    count++;
                    }
                }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = "+count);
    }

运行结果如下图:
在这里插入图片描述

对这个案例进行几次修改

(1)接下来我们可以考虑,当锁对象为两个:object1和object2时,两个线程分别竞争两个不一样的锁,会出现什么情况
在这里插入图片描述
结果如下:
在这里插入图片描述
通过这个改变,我们可以理解不同的锁对象之间不存在互斥关系,因此二者之间也就不会发生锁竞争。

(2)将synchronized放在for循环外面的情况
在这种条件下,意味着当t1或t2某一个线程拿到这把锁之后,只有等循环结束以后才能释放了,很明显这样的情况所花费的资源甚至多于单线程。

Thread t1 = new Thread(()->{
            synchronized (object1) {
            for (int i = 0; i < 50000; i++) {
                        count++;
                    }
            }
        });
        Thread t2 = new Thread(()->{
            synchronized (object1) {
                for (int i = 0; i < 50000; i++) {
                    count++;
                    }
                }
        });

(3)在下面的代码中,设计Counter类进行add和get操作,在上面的代码中,我们已经知道锁对象只是一个标识,不关心它是怎样的存在,因此在这里,我们大胆的把counter对象作为锁对象。

class Counter {
    public int count;

    public  void add() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
public class Demo3 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (counter) {
                    counter.add();
                }
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                synchronized (counter) {
                    counter.add();
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = "+counter.getCount());
    }
}

运行结果如下图:
在这里插入图片描述
(4)如果我们把锁加到add()方法中,我们通过this来指代对应的对象,这样做的情况是当多个线程调用该方法的时候,如果使用的是同一个对象会进行竞争,如果是不同对象的话则不会进行竞争。
同含义的写法为:synchronized public void add()

class Counter {
    public int count;

     public void add() {
     	synchronizedthis){
        	count++;
        }
    }

    public int getCount() {
        return count;
    }
}

Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    counter.add();
                }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                    counter.add();
            }
        });

(5)对静态方法加锁
与(4)不同的是,加锁的对象为Counter这个类对象,因此如果多个线程调用func方法,则这些线程之间都会进行锁竞争

//第一种写法
public static void func(){
  synchronized (Counter.class) {
    //func
    }
}
//第二种写法
synchronized public static void func(){
  //func
}
   

总结

对于锁的概念需要逐渐深入,在本文中讲解了锁引入的原因以及锁的几种写法。
本文中使用的源码请戳此处

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值