Android 多线程 线程同步

3,564 阅读6分钟

线程同步

为什么引入同步机制?

多线程因为存在共享资源,为了保证其原子性,保证线程安全,必须引入同步机制。

一、必要的概念

因为多线程的共享内存,当多线程对共享内存进行操作的时候,就存在两个大问题必须解决:竞态条件、内存可见性

1、竞态条件

当多线程访问和操作同一对象的时候,如果对资源访问的访问顺序敏感,就称存在竞态条件。

例子:

比如线程A、B都从内存拿数据count,A对数据+2,B对数据+3,所以按照我们期待的结果最终写入内存的数据应该是5。但是其实不是这样的,因为当初A、B从内存拿数据count的时候,数据count是0,所以最终count的结果取决于A、B谁后写入内存谁后写入,count就是对应的执行结果。

常用的解决方法:

  • 使用synchronized关键字
  • 使用显式锁(Lock)
  • 使用原子变量

2、内存可见性

关于内存可见性的问题首先要从内存和cpu的配合谈起,内存是一个硬件,执行速度比CPU慢几百倍,所以在计算机中,CPU执行运算的时候,不会每次运算都和内存进行数据交互,而是先把一些数据写入CPU中的缓存区(寄存器和各级缓存),在结束以后再写入内存。这个过程是极其快的,单线程下是没有任何问题的。

但是多线程就出现了问题,一个线程对内存中的一个数据做出了修改,但是并没有及时写入内存(暂时放在缓存中);这时候另一个线程对同样的数据进行修改的时候拿到的就是内存中还没有被修改的数据,也就是说一个线程对一个共享变量的修改,另一个线程不能马上看到,甚至永远看不到。就跟上面的例子也是一样的,不过竞态条件关注的是因为执行顺序而带来的结果的不同,而内存可见性强调的是数据的原子性。

这就是内存可见性问题。

常用的解决方法:

  • 使用volatile关键字
  • 使用synchronized关键字
  • 使用显式锁同步

二、线程同步

synchronized 方法(同步方法)

从java 1.0 版开始,Java中的每个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就说,要调用该方法,线程必须获得内部的对象锁。避免了程序员使用显式锁的方式lock和unlock。

代码如下:

public class synchronizedTest {
    private  int money = 100;
    public  synchronized int getMoney(int number){
        if(number < 0){
            return -1;
        }else if(number > money){
            return -2;
        }else if(money < 0){
            return -3;
        }else{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        money -= number;
        System.out.println("存款剩余" + money);
        return number;
    } 
}

等价于

public class synchronizedTest {
    Lock mLock = new ReentrantLock();
    private  int money = 100;
    public int getMoney(int number){
        mLock.lock();
        try{
            if(number < 0){
                return -1;
            }else if(number > money){
                return -2;
            }else if(money < 0){
                return -3;
            }else{
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            money -= number;
            System.out.println("存款剩余" + money);
            return number;
        }finally{
            mLock.unlock();
        }
       
    } 
}

synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类

同步代码块

即有synchronized关键字修饰的语句块。被关键字修饰的语句块会自动被加上内置锁,从而实现同步。

public class synchronizedTest {
    private  int money = 100;
    public int getMoney(int number){
    	synchronized(this){
    	 if(number < 0){
            return -1;
        }else if(number > money){
            return -2;
        }else if(money < 0){
            return -3;
        }else{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        money -= number;
    	}
    	System.out.println("存款剩余" + money);
        return number;
    } 
}

同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没必要同步整个方法,使用synchronized代码块同步关键代码即可。

重入锁

synchronized关键字自动提供了锁以及相关的条件,大部分需要显式锁的情况使用synchronized非常方便,但是了解重入锁和条件对象,就能更好的理解synchronized关键字。重入锁ReentrantLock是Java SE 5.0 引入的,我在前面的代码类比过了,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。

ReentrantLock具有和synchronized相似的作用,但是更加的灵活和强大。
它是一个重入锁(synchronized也是),所谓重入就是可以重复进入同一个函数,这有什么用呢?
假设一种场景,一个递归函数,如果一个函数的锁只允许进入一次,那么线程在需要递归调用函数的时候,应该怎么办?退无可退,有不能重复进入加锁的函数,也就形成了一种新的死锁。
重入锁的出现就解决了这个问题,实现重入的方法也很简单,就是给锁添加一个计数器,一个线程拿到锁之后,每次拿锁都会计数器加1,每次释放减1,如果等于0那么就是真正的释放了锁。

用RenntrantLock保护代码块的结构如下所示:

Lock mLock = new ReentranLock();
mLock.lock();
try{
..
}finally{
	mLock.unlock();
}

条件对象

看上面就知道,重入锁保证了同步问题,但是可能还存在这样的一个场景,一个线程好不容易抢到了锁,但是却发现再某个条件满足以后,它才能执行。这时可以使用一个条件对象来管理那些已经已经获取一个锁但是却不能做有用工作的线程,条件对象又被称为条件变量。通过下面的例子来说明为何需要条件对象。

假设一个从场景需要支付宝转账。我们首先写了支付宝的类,它的构造方法需要传入支付宝账户的数量和每个账户的账户金额。

public class Alipay {
    private double[] accounts;
    private Lock alipayLock;

    public Alipay(int n,double money){
        accounts = new double[n];
        alipayLock = new ReentrantLock();
        for(int i=0; i<accounts.length ; i++){
            accounts[i] = money;
        }
    }
}

接下来我们要转载,写一个转账方法,from是转账方,to是接收方,amount是转账金额,如下所示:

  public void transfer(int from,int to,int amount){
        alipayLock.lock();
        try{
            while(accounts[from] < amount){
                //wait
            }
        }finally{
            alipayLock.unlock();
        }
    }

结构我们发现转账方余额不足;如果有其他线程给这个转账方再转足够的钱,那就可以进行转账操作了。但是当前这个线程已经获取了锁了,别的线程无法获取锁来进行存款操作了,这就是我们需要引入条件对象的原因。一个锁有多个相关的条件对象,可以用newCondition方法获取一个条件对象,我们得到条件对象后调用await()方法,当前线程就被阻塞了并放弃了锁。加入条件对象,代码如下:

public class Alipay {
    private double[] accounts;
    private Lock alipayLock;
    private Condition condition;

    public Alipay(int n,double money){
        accounts = new double[n];
        alipayLock = new ReentrantLock();
        //获得条件对象
        condition = alipayLock.newCondition();
        for(int i=0; i<accounts.length ; i++){
            accounts[i] = money;
        }
    }
    public void transfer(int from,int to,int amount) throws InterruptedException{
        alipayLock.lock();
        try{
            while(accounts[from] < amount){
                //阻塞当前线程,并放弃锁
                condition.await();
            }
        }finally{
            alipayLock.unlock();
        }
    }
}

一旦一个线程调用await()方法,它就会进入该条件的等待集并处于阻塞状态,知道另一个线程调用了同一个条件的signAll方法为止。当另一个线程转账给我们此前的转账方时,只要调用condition.signAll(),就会重新激活因为这一条件而等待的所有线程。代码如下所示:

public void transfer(int from,int to,int amount) throws InterruptedException{
        alipayLock.lock();
        try{
            while(accounts[from] < amount){
                //阻塞当前线程,并放弃锁
                condition.await();
            }
            //转账的操作
            accounts[from] = accounts[from] - amount;
            accounts[to] = accounts[to] + amount;
            condition.signalAll();
        }finally{
            alipayLock.unlock();
        }
    }

当调用signalAll方法时不是立即激活一个等待线程,它仅仅接触了等待线程的阻塞,以便这些线程能够再当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法是signal(),它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞。如果没有其他线程再次调用signal(),则系统死锁。

代码合并如下:

public class Alipay {
    private double[] accounts;
    private Lock alipayLock;
    private Condition condition;

    public Alipay(int n,double money){
        accounts = new double[n];
        alipayLock = new ReentrantLock();
        //获得条件对象
        condition = alipayLock.newCondition();
        for(int i=0; i<accounts.length ; i++){
            accounts[i] = money;
        }
    }
    public void transfer(int from,int to,int amount) throws InterruptedException{
        alipayLock.lock();
        try{
        
            while(accounts[from] < amount){
                //阻塞当前线程,并放弃锁
                condition.await();
            }
            //转账的操作
            accounts[from] = accounts[from] - amount;
            accounts[to] = accounts[to] + amount;
            condition.signalAll();
        }catch(InterruptedException exception){
            exception.printStackTrace();
        }
        finally{
            System.out.println(Thread.currentThread().getName());
            System.out.println(Thread.currentThread().getState());
            alipayLock.unlock();
        }
    }
    public static void main(String[] args) {
        Alipay alipay = new Alipay(5, 5000);
            new Thread(){
                public void run(){
                    try {
                         alipay.transfer(0, 1, 5000);
                    alipay.transfer(0, 3, 5000); 
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                  
                };
            }.start();
            new Thread(){
                public void run(){
                    try {
                       alipay.transfer(2, 0, 5000); 
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    
                };
            }.start();
        
    }
}
结果如下:
Thread-0
RUNNABLE
Thread-1
RUNNABLE
Thread-0
RUNNABLE

三、volatile

有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销很大;而volatile关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

了解volatile关键字之前,我们需要了解一下内存模型的相关概念以及并发编程的3个特性:原子性、可见性和有序性。

Java内存模型

Java中的堆内存是被所有线程共享的运行时内存区域,因此,它存在可见性问题。而局部变量、方法定义的参数则不会再线程之间共享,它们不会存在内存可见性问题,也不受内存模型的影响。

Java内存模型定义了线程和主存之间的抽象关系:

  • 线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。
  • 本地内存是Java内存模型的一个抽象概念,并不实际存在,它涵盖了缓存、写缓存区、寄存器等区域。
  • Java内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。

Java内存模型的抽象示意图如下:

14923529-55ffcf1994434a48

原子性、可见性和有序性

  1. 原子性

    对基本数据类型变量的读取和赋值操作时原子性操作,即这些操作时不可被中断的,要么执行完毕,要么不执行。如下代码:

    x = 3;	//语句1
    y = x;	//语句2
    x++;	//语句3
    

    上面的三个语句中,只有语句1是原子性的,其余两个都不是原子性操作。

    语句2包含了两个操作:读取x的值,然后写入工作内存。

    语句3包含三个操作:读取x的值,对x的值加1,写入工作内存。

    总而言之,只有简单的读取和赋值才是原子性的操作,其余的都不是。当然后面有别的类型来限定原子性,比如AtomicInterger,但是这不是基本类型。

  2. 可见性

    可见性,指线程之间的可见性,一个线程修改的状态对于另一个线程是可见的,也就是一个线程修改的结果,对于另一个线程是立马可见的。

  3. 有序性

    Java内存模型中允许编译器和处理器对指令进行重排序,vilatile可以保证有序性,禁止只有指令重排序。

volatile关键字

当一个共享变量被volatile修饰,就具备两个含义,一个是保证其可见性,一个是保证其有序性。

但是其不保证原子性。

public class volatileTest {
    public volatile int inc = 0;
    public void increase(){
        inc++ ;
    }
    public static void main(String[] args) {
        final volatileTest test = new volatileTest();
        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() > 2){
            Thread.yield();
        }
        System.out.println(test.inc);
    }
    
}

这段代码每次运行结果都不一致,这里就表现了volatile不能保证原子性。

因为之前讲过了自增操作不是原子性操作的,所以这里的increase()里面的自增操作可能会分割开执行。

  • 加入某个时刻inc为9,线程1读取,然后线程1被阻塞了
  • 线程2读取,然后自增,结束,将inc=10写入内存
  • 线程1重新获取,但是此时线程1之前已经读取了内存的值存在本地副本中了为9,这时候自增就为10,然后写入内存10
  • 所以两个线程都对inc进行了一次自增操作,本该为11,但是现在就为10
  • 所以volatile不保证原子性

正确的使用volatile关键字

synchronized关键字可防止多个线程同时执行一段代码,但是,开销大,并且影响执行效率,而volatile关键字某些情况下的性能是优于synchronized的,但是注意的是volatile无法替代synchronized,因为其无法保证原子性。

通常来说,使用volatile必须保证以下两个条件:

  1. 对变量的写操作不会依赖当前值
  2. 该变量没有包含在具有其他变量的不变式中

第一个条件就是说,必须为原子性操作,比如赋值,不能是自增这样的非原子性操作。

第二个条件来举一个例子,它包含了一个不变式:下界总是小于或者等于上界,代码如下所示:

public class NumberRange {
    private volatile int lower,upper;
    public int getLower(){
        return lower;
    }
    public int getUpper(){
        return upper;
    }
    public void setLower(int value){
        if(value > upper)
            throw new IllegalAccessException(...);
        lower = value;
    }
    public void setUpper(int value){
        if(value < lower)
            throw new IllegalAccessException(...);
        upper = value;        
    }
}

这种方式将lower、upper字段定义为volatile类型不能够充分实现类的线程安全。如果当两个线程在同一时间使用不一致的值执行setLower和setUpper的话,则会使范围处于不一致的状态。例如初始状态是(0,5),在同一时间内,线程A调用setLower(4)并且线程B调用setUpper(3),虽然这两个操作交叉存入的值是不符合条件的,但是这两个线程都会通过用于保护不变式的检查,使得最后的范围是(4,3)。这显然是不对的,因此使用volatile是无法保证原子性的。

参考学习

juejin.cn/post/684490…

juejin.cn/post/684490…