在Android应用中,当我们使用多个线程来访问同一个数据时,非常容易出现线程安全问题,比如同时访问某一资源会造成资源不一致等现象,所以就有了我们今天这个话题:Android中的线程同步问题


内存模型

共享内存

我们知道计算机执行每条指令都是在CPU中执行的,而执行指令过程中,会伴随着数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取和写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

android thread 同步机制 安卓线程同步_android thread 同步机制

这时,如果我们是多线程访问(满足多个CPU的情况下),每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。

比如我们运行 i = i + 1 ,初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1执行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。最终结果i的值是1,而不是2,这就是缓存一致性问题。这种被多个线程访问的变量为共享变量。

并发编程

在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。这其实也就是Java的线程安全性所关注的三个问题

1. 原子性

atomic原子操作是CPU执行指令的最小单元,所谓原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始就一直运行到结束,中间不会有任何分割中断行为。

比如a++这个语句,这种操作在编译后实际是多条语句,比如先读a的值,再加1操作,再写操作,执行了3个原子操作和 a = a+1 一样,如果并发情况下,另外一个线程很有可能读到了中间状态,从而导致程序语意上的不正确,所以a++实际是一个复合操作。 又如y = x ,先要去读取x的值,再将x的值写入工作内存,所以也不是一个原子性操作。只有类似于 y = 10这样的一步操作属于原子性操作。

所以,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作,Java内存模型只保证了基本读取和赋值是原子性操作

加锁可以保证复合语句的原子性,sychronized和Lock可以保证多条语句在synchronized块中语意上是原子的, 但是volatile并不能保证

2. 可见性

可见性是指一个线程对变量的写操作对其他线程后续的读操作是可见的。由于现代CPU都有多级缓存,CPU的操作都是基于高速缓存的,而线程通信是基于内存的,这中间有一个时间间隔, 这个间隔就是变量从缓存中刷回主存的时间。

普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

Java提供了volatile关键字来保证可见性

3. 有序性

有序性指的是数据不相关的变量在并发的情况下,实际执行的结果和单线程的执行结果是一样的。产生这个问题主要的原因就是因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。

因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

volatile, final, synchronized和Lock,显式锁都可以保证有序性


线程同步

通过上面的分析,我们应该知道共享数据在多线程的情况下存在很多不确定性,那么我们如何保证数据的同步呢,这就需要一些方式进行线程的安全同步,java和android中我们有以下几种方式可以保证线程安全


volatile 说明

在Java中,当我们声明共享变量为volatile后,对这个变量的读/写将会很特别。Volatile修饰的成员变量在每次被线程访问时,都强迫从共享内存中重读该成员变量的值。而且,当成员变量发生变化时,强迫线程将变化值回写到共享内存(主存)。这样在任何时刻,两个不同的线程总是看到某个成员变量的同一个值,这就保证了变量可见性的特征。
在保证原子性方面Volatile就没有那种效果了,我们来看下面这个代码

public class MyClass {

    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final MyClass test = new MyClass();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  
            Thread.yield();
        System.out.println(test.inc);
    }
}

运行结果并不是我们期待的10000,而是小于10000,这里就涉及到为什么不能保证原子性的问题了,可见性只保证了每次拿到的是最新的值,但是由于自加(++)并不是原子操作,所以,在多个线程同时++时,某一个线程拿到主存中的最新值开始第一步 “+1” 操作,然后在还没有赋值的时候线程阻塞(不是原子操作),这时另一个线程从主存中拿取这个 “最新值” 并不是那个阻塞线程加一后的值,因为那个线程还没有将最新值存贮到主存中。

这也就说明自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。那么有什么方法能够满足这个操作是原子性操作么,就要通过加锁的方式


synchronized 说明

synchronized是一个同步锁,他可以修饰一个代码块、方法、静态方法或者一个类,作用范围都是 “{ }” 之间的部分,作用对象都是调用该方法的对象或synchronized类的本身对象。

我们在用synchronized关键字的时候,能缩小代码段的范围就尽量缩小,能在代码块上加同步就不要再整个方法上加同步。这叫减小锁的粒度,使代码更大程度的并发。因为锁的代码段太长了,别的线程是是要等很久的。

1.修饰一个代码块

synchronized(this)

注意:synchronized锁住的是括号里的对象,而不是代码!
对于非static的synchronized方法,锁的就是对象本身也就是this。

当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。

这里说明两点
1. 当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块

下面我们来看一段代码,分析一下为什么这个代码块没有被 “锁住”

/**
 * @since 16/4/23.
 */
public class MyClass {

    public static void main(String[] args) {

        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    new SynClass().test();
                }
            }.start();
        }
    }
}

class SynClass {

    public void test() {
        synchronized (this) {
            System.out.println("start...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("stop");
        }
    }

}

输出结果是这样:
start…
start…
start…
stop
stop
stop
我们可以看到,这里有三个线程且分别创建了三个SynClass对象,根据我们以上分析,synchronized锁住的是括号里的对象而不是代码,而这里有三个创建出来的对象,所以并不满足锁方法块的要求,我们改动一下

/**
 * @since 16/4/23.
 */
public class MyClass {

    public static void main(String[] args) {
        final SynClass syn = new SynClass();
        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    syn.test();
                }
            }.start();
        }
    }
}

class SynClass {

    public void test() {
        synchronized (this) {
            System.out.println("start...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("stop");
        }
    }

}

这样就可以有序输出了,如果我们不采用一个对象,还是像上面那种new出三个对象来执行,有方法吗,答案是有的只需要改变一下锁的对象,可以自己试一下

synchronized(SynClass.class)

从而实现了全局锁的效果,这种方式就是我们要说的修饰一个类的做法

2. 修饰一个方法

public synchronized void method()

这种方式和这种锁代码块是等价的
public void method() { synchronized(this) { // to do something} }
都是锁定了整个方法时的内容

所以也就表明这个也是针对于一个对象的锁定

这里还要说明一点,虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。

当然,还可以在子类方法中通过 super.method() 调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。

3. 修饰一个静态方法

public synchronized static void method() {
// todo
}

静态方法是属于类的,所以synchronized修饰的静态方法锁定的是这个类的所有对象

还是那段代码,这次我们将SynClass的test方法改为 static 方法,然后我们在执行一次

/**
 * @since 16/4/23.
 */
public class MyClass {

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    SynClass.test();
                }
            }.start();
        }
    }
}

class SynClass {

    public static synchronized void test() {

        System.out.println("start...");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("stop");

    }

}

输出结果:
start…
stop
start…
stop
start…
stop
可以看到,即便我们每一个线程中都创建了一个新的对象,但是并没有影响到线程执行的同步问题,所以看得出来这种方式是对类的锁定,而不是仅仅针对类中的一个对象。

4. 修饰一个类

还是那个例子,我们直接看代码

/**
 * @since 16/4/23.
 */
public class MyClass {

    public static void main(String[] args) {
        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    new SynClass().test();
                }
            }.start();
        }
    }
}

class SynClass {

    public void test() {
        synchronized (SynClass.class) {
            System.out.println("start...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("stop");
        }
    }

}

同样,虽然在每个线程中都创建了一个类的对象,但是这里的锁是针对这个类的,不是这个类的某个单一对象,这也就能实现锁住代码而不是对象的方式。

根据以上分析我们可以大致将锁进行一下归类

类锁:在代码中的 static 方法上加了synchronized的锁,或者synchronized(xxx.class)的代码段

对象锁:在代码中的普通方法上加了synchronized的锁,或者synchronized(this)的代码段


Lock 说明

Lock 实现提供了比使用synchronized 方法和语句可获得的更广泛的锁定操作,它能以更优雅的方式处理线程同步问题。需要注意的是,用sychronized修饰的方法或者语句块在代码执行完之后锁自动释放,而用Lock需要我们手动释放锁,所以为了保证锁最终被释放(发生异常情况),要把互斥区放在try内,释放锁放在finally内。

1. Lock 对象锁

Lock的对象锁也可以很好的起到锁住对象的功能,还是上面那个功能我们通过Lock来试一下

/**
 * @since 16/4/23.
 */
public class MyClass {
    public static void main(String[] args) {
        final SynClass syn = new SynClass();
        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    syn.test();
                }
            }.start();
        }
    }
}
class SynClass {
    private Lock mLock = new ReentrantLock();
    public void test() {
        mLock.lock();
        System.out.println("start...");
        try {
            Thread.sleep(1000);
            System.out.println("stop");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            mLock.unlock();
        }
    }
}

这里同样实现了顺序的执行,也就是说Lock也具备对象锁的功能,接下来我们看一下 Lock 还具备什么功能

2. Lock 读写锁

Lock的好处肯定会有 synchronized 不可比拟的地方,比如读写锁就具备这个特征。

现在我们来模拟一个场景,有三个线程同时通过读写操作来修改一组数据,我们来看一下没有线程安全的情况下的代码和执行效果

/**
 * @since 16/4/23.
 */
public class MyClass {

    public static int data = 0;

    public static void main(String[] args) {
        //三个线程同步数据
        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    SynClass syn = new SynClass();
                    syn.put(Thread.currentThread().getName());
                    syn.get(Thread.currentThread().getName());
                }
            }.start();
        }
    }
}

class SynClass {

    private Random random = new Random();

    /**
     * 存储数据
     */
    public void put(String threadName) {
        int curData = random.nextInt(20);
        MyClass.data = curData;
        System.out.println(threadName + "存入数据:" + curData);
    }

    /**
     * 取出数据
     */
    public void get(String threadName) {
        System.out.println(threadName + "取出数据值为:" + MyClass.data);
    }
}

执行后会产生很多种结果,以下是其中的一种情况

android thread 同步机制 安卓线程同步_计算机_02

我们可以看到,数据是混乱的,当然我们可以通过 synchronized 的方式来保证线程安全。

为了保证数据的一致性和完整性,需要读和写是互斥的,写和写是互斥的,但是读和读是不需要互斥的,这样读和读不互斥性能更高些,这时候我们考虑,我们如何通过 synchronized 来实现这种操作,因为要满足读和写、写和写都互斥,所以我们要在这两个方法上都上锁,这样的话其实 读取和读取 之间也互斥了,不能并发执行,效率较低。这时我们就可以通过 Lock 的读写锁实现这个功能

/**
 * @since 16/4/23.
 */
public class MyClass {

    public static int data = 0;

    public static void main(String[] args) {
        final SynClass syn = new SynClass();
        //三个线程同步数据
        for (int i = 0; i < 15; i++) {
            new Thread() {
                @Override
                public void run() {
                    syn.put(Thread.currentThread().getName());
                    syn.get(Thread.currentThread().getName());
                }
            }.start();
        }
    }
}

class SynClass {

    private ReadWriteLock rwl = new ReentrantReadWriteLock();

    private Random random = new Random();

    /**
     * 存储数据
     */
    public void put(String threadName) {
        rwl.writeLock().lock();
        int curData = random.nextInt(20);
        MyClass.data = curData;
        System.out.println(threadName + "存入数据:" + curData);
        rwl.writeLock().unlock();
    }

    /**
     * 取出数据
     */
    public void get(String threadName) {
        rwl.readLock().lock();
        System.out.println(threadName + "取出数据值为:" + MyClass.data);
        rwl.readLock().unlock();
    }
}

注意,这里的Lock锁同样是针对对象,所以我们在每一个对象中都是都一个对象,我们可以看一下执行效果

android thread 同步机制 安卓线程同步_计算机_03

我们可以看到,读取过程并没有受到写入的影响,也没有混乱发生,同时读和读之间没有相互制约的效果

这里我们简单的结合锁来总结了一下Android中线程安全的问题