线程安全数据同步之 synchronized 与 Lock

Posted 超超boy

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线程安全数据同步之 synchronized 与 Lock相关的知识,希望对你有一定的参考价值。

本文Demo下载传送门

写在前面

本篇文章讲的东西都是android开源网络框架NoHttp的核心点,当然线程、多线程、数据安全这是Java中就有的,为了运行快我们用一个Java项目来讲解。

为什么要保证线程安全/数据同步

当多个子线程访问同一块数据的时候,由于非同步访问,所以数据可能被同时修改,所以这时候数据不准确不安全。

现实生活中的案例

假如一个银行帐号可以存在多张银行卡,三个人去不同营业点同时往帐号存钱,假设帐号原来有100块钱,现在三个人每人存钱100块,我们最后的结果应该是100 + 3 * 100 = 400块钱。但是由于多个人同时访问数据,可能存在三个人同时存的时候都拿到原账号有100,然后加上存的100块再去修改数据,可能最后是200、300或者400。这种清情况下就需要锁,当一个人操作的时候把原账号锁起来,不能让另一个人操作。

案例(非线程安全)代码实现:

1、程序入口,启动三个线程在后台循环执行任务,添加100个任务到队列:

/**
 * 程序入口
 */
public void start() {
    // 启动三个线程
    for (int i = 0; i < 3; i++) {
        new MyTask(blockingQueue).start();
    }
 
    // 添加100个任务让三个线程执行
    for (int i = 0; i < 100; i++) {
        Tasker tasker = Tasker.getInstance();
        blockingQueue.add(tasker);
    }
}

 2、那我们再来看看MyTask这个线程是怎么回事,它是怎么执行Tasker这个任务的。

public class MyTask extends Thread {
 
    ...
 
    @Override
    public void run() {
        while (true) {
            try {
                Tasker person = blockingQueue.take();
                person.change();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
 
}

 

分析一下上面的代码,就是一直等待循环便利队列,每拿到一个Tasker时去调用void change()方法让Tasker在子线程中执行任务。

3、我们在来看看Tasker对象怎么执行,单例模式的对象,被重复添加到队列中执行void change()方法:

public class Tasker implements Serializable, Comparable<Tasker> {
 
    private static Integer value = 0;
 
    public void change() {
        value++;
        System.out.println(value);
    }
    ...
}

  我们来分析一下上面的代码,void change()每被调用一次,属性value的值曾加1,理论上应该是0 1 2 3 4 5 6 7 8 9 10…这样的数据被打印出来,最差的情况下也是1 3 4 6 5 2 8 7 9 10 12 11…这样顺序乱一下而已,但是我们运行起来看看:

技术分享

 

我们发现了为什么会有3 4 3 3 这种重复数据出现呢?嗯对了,这就是文章开头说的多个线程拿到的value字段都是2,然后各自+1后打印出来的结果都是3,如果应用到我们的银行系统中,那这不是坑爹了麽,所以我们在多线程开发的事后就用到了锁。

多线程保证数据的线程安全与数据同步

多线程开发中不可避免的要用到锁,一段被加锁的代码被一个线程执行之前,线程要先拿到执行这段代码的权限,在Java里边就是拿到某个同步对象的锁(一个对象只有一把锁),如果这个时候同步对象的锁被其他线程拿走了,这个线程就只能等了(线程阻塞在锁池等待队列中)。拿到权限(锁)后,他就开始执行同步代码,线程执行完同步代码后马上就把锁还给同步对象,其他在锁池中等待的某个线程就可以拿到锁执行同步代码了。这样就保证了同步代码在统一时刻只有一个线程在执行。Java中常用的锁有synchronized和Lock两种。
锁的特点:每个对象只有一把锁,不管是synchronized还是Lock它们锁定的只能是某个具体对象,也就是说该对象必须是唯一的,才能被锁起,不被多个线程同时使用。

synchronized的特点

同步锁,当它锁定的方法或者代码块发生异常的时候,它会在自动释放锁;但是如果被它锁定的资源被线程竞争激烈的时候,它的表现就没那么好了。

1、我们来看下下面这段代码:

// 添加100个任务让三个线程执行
for (int i = 0; i < 100; i++) {
    Tasker tasker = new Tasker();
    blockingQueue.add(tasker);
}

  这段代码是文章最开头的一段,只是把Tasker.getInstance()改为了new Tasker();,我们现在给Tadkervoid change()方法加上synchronized锁:

/**
 * 执行任务;synchronized锁定方法。
 */
public synchronized void change() {
    value++;
    System.out.println(value);
}

  我们再次执行后发现,艾玛怎么还是有重复的数字打印呢,不是锁起来了麽?但是细心的读者注意到我们添加Tasker到队列中的时候是每次都new Tasker();,这样每次添加进去的任务都是一个新的对象,所以每个对象都有一个自己的锁,一共3个线程,每个线程持有当前task出的对象的锁,这必然不能产生同步的效果。换句话说,如果要对value同步,那么这些线程所持有的对象锁应当是共享且唯一的!这里就验证了上面讲的锁的特点了。那么正确的代码应该是:

Tasker tasker = new Tasker();
for (int i = 0; i < 100; i++) {
    blockingQueue.add(tasker);
}

  或者给这个任务提供单例模式:

for (int i = 0; i < 100; i++) {
    Tasker tasker = Tasker.getInstance();
    blockingQueue.add(tasker);
}

 这样对象是唯一的,那么public synchronized void change()的锁也是唯一的了。

2、难道我们要给每一个任务都要写一个单例模式麽,我们每次改变对象的属性岂不是把之前之前的对象属性给改变了?所以我们使用synchronized还有一种方案:在执行任务的代码块放一个静态对象,然后用synchronized加锁。我们知道静态对象不跟着对象的改变而改变而是一直在内存中存在,所以:

private static Object object = new Object();
 
public void change() {
    synchronized (object) {
        value++;
        System.out.println(value);
    }
}

  这样就能保证锁对象的唯一性了,无论我们用new Tasker();Tasker.getInstance();都不受影响。
我们知道,对于同步静态方法,对象锁就是该静态放发所在的类的Class实例,由于在JVM中,所有被加载的类都有唯一的类对象,具体到本例,就是唯一的Tasker.class对象。不管我们创建了该类的多少实例,但是它的类实例仍然是一个。所以我们上面的代码也可以改为:

public void change() {
    synchronized (Tasker.class) {
        value++;
        System.out.println(value);
    }
}

 根据上面的经验,我们的Tasker.getInstance();方法的具体应该就是:

以上是关于线程安全数据同步之 synchronized 与 Lock的主要内容,如果未能解决你的问题,请参考以下文章

Linux 多线程:线程安全之同步与互斥的理解

高并发编程原理与实战.线程安全.锁原理.同步容器.实战之JAVA架构

线程同步之读写锁

Java并发编程之线程安全线程通信

并发与并行同步与异步,线程安全的实现

并发与并行同步与异步,线程安全的实现