java中的各种同步方法--syncharonized、Lock、Volatile、原子变量(Android通用)

在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取时,这时往往我们为了保证数据的正确性、甚至不发生异常需要对相关的数据进行加锁处理。

我们首先看一个例子:有一个银行,里面有一百个账户,每一个账户里面有1000块钱,100个账户总共是100000元,每一个账户都有自己一个独立的进程,现在我们让每一个账户都不停的向其他的账户转入随机的金额。代码如下:

public class BankTest{

    /**
     * 一共100个账户
     */
    public static final int NACCOUNTS = 100;
    /**
     * 每个账号有1000元
     */
    public static final double INITIAL_BALANCE = 1000;
    /**
     * 最大转账金额
     */
    public static final double MAX_AMOUNT = 100;
    public static final int DELAY = 10;

    public static void test(){
        final Bank bank=new Bank(NACCOUNTS,INITIAL_BALANCE);
        for(int i=0;i

我们现在开始执行,打印日志如下(我只筛选了其中的总金额日志):

I/System.out: Bank: Total Balance:   97810.11
I/System.out: Bank: Total Balance:   97845.88
I/System.out: Bank: Total Balance:   97500.47
I/System.out: Bank: Total Balance:   97222.38
I/System.out: Bank: Total Balance:   97501.90
I/System.out: Bank: Total Balance:   97096.04
I/System.out: Bank: Total Balance:   97308.89
I/System.out: Bank: Total Balance:   97605.74

我们可以看到100个账号的总金额有时增有时减,却不是正确的100000,这就是因为有多个线程可能同时操作了共同一个账号,导致这个账户的金额出现错误--》总金额也就出现的错误。

注:我们上面增加的两条日志主要是为了增大错误出现的频率,如果把两条日志都去掉的话,出现的错误几率会大大减小,但是还是会有一定几率出现的。

我们现在分析一下具体是什么原因:

我们看一下Bank类中的transfer方法中的accounts[to]+=account这一句代码,这个指令会被处理为如下3步:

1、将account[to]加载到寄存器

2、增加account

3、将结果写回到account[to]

现在假设有两个线程同时执行到了这条命令,其中线程1执行了步骤1和2,这时,它被剥夺了运行权。同时线程2被唤醒并修改了accounts数组中的同一项,也就是同时执行了步骤1/2/3。然后,线程1被唤醒并完成其第三步。这样就会导致线程2中的第三步所做的更新会被线程1的第三步更新所抹掉,于是,总金额不再正确。

由此看错误的原因其实是Bank的transfer方法在执行过程中有可能会被中断。如果能够确保线程在失去控制前方法运行完成,那么银行账户对象的金额就永不会出现错误。

下面我们就看一下java提供的实现上述需求的方式:

一、ReentrantLock

(1)基本使用:

    //加锁
    mLock.lock();
    try{
        //具体执行逻辑
    }finally{
        //释放锁
        mLock.unLock();
    }

这一结构确保任何时刻只有一个线程进入临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过locak语句。当其他线程调用lock时,它们被阻塞,直到第一个线程释放锁对象。

注:把解锁操作放到finally子句之内是至关重要的。如果在整个逻辑中的某一行代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞

我们看一下加锁后的transfer方法代码:

    private Lock bankLock = new ReentrantLock();

    public void transfer(int from, int to, double amount){
        bankLock.lock();
        try{
            if(accounts[from] < amount){
                return;
            }
            accounts[from] -= amount;
            System.out.printf(TAG + ":" + " %10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(TAG + ":" + " Total Balance: %10.2f%n", getTotalBalance());
        }finally{
            bankLock.unlock();
        }
    }

假设一个线程调用transfer,在执行结束前被剥夺了运行权。假定第二个线程也调用了transfer,由于第二个线程不能获取锁,将在调用lock方法时被阻塞。它必须等待第一个线程完成transfer方法的执行之后才能再度被激活。当第一个线程释放锁时,那么第二个线程才能开始运行。

看下打印日志:

I/System.out: Bank:      98.44 from 35 to 27Bank: Total Balance:  100000.00
I/System.out: Bank:      97.03 from 86 to 63Bank: Total Balance:  100000.00
I/System.out: Bank:      78.83 from 17 to 40Bank: Total Balance:  100000.00
I/System.out: Bank:      37.84 from 77 to 19Bank: Total Balance:  100000.00

注意,每一个bank对象都有自己的ReentrantLock对象。如果两个线程试图访问同一个bank对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的Bank对象,每一个线程得到不同的锁对象,两个线程都不会方式阻塞。

锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。

例如:transfer方法调用getTtalBalance方法,我们也可以在getTtalBalance方法中加入banklock的锁,此时banklock对象的持有计数为2,。当getTtalBalance方法退出的时候,持有计数变回1.当transfer方法退出的时候,持有计数变为0,线程释放锁。

    public double getTotalBalance(){
        bankLock.lock();
        try{
        double sum = 0;
        for(double a : accounts){
            sum += a;
        }
        return sum;
        }finally{
            bankLock.unlock();
        }
    }

注:其实ReentrantLock除空构造函数外,还有一个带参构造函数:ReentrantLock(boolean fair);含义为:构建一个带有公平策略的锁。一个公平锁偏爱等待时间最长的线程(也就是那个先等待的就哪个先释放),但是这一公平的保证将大大降低性能。除非项目需求确实需要,否则尽量别用。

1.1 ReentrantLock锁的条件对象-----await() 、signalAll()、signal()

比如此时我们新加一需求,当账号存款余额小于要转账金额时,并不马上结束而是等待其他账号向此账号中转入一笔钱并够要转账的金额时,继续向其他账号转账。

这时就需要“条件对象”了。一个锁对象可以有一个或者多个条件对象,可以使用newCondition方法获取一个条件对象。在这里我们就创建一个金额充足的条件对象:sufficientFunds。

如果transfer方法发现余额不足了,那么它调用sufficientFunds.await()方法,并放弃了锁。这时其他线程可以进行增加账号余额的操作了。同时,其他线程一旦进行了转账操作则要调用sufficientFunds.signalAll()方法来换醒所有等待中的线程。

修改后的代码如下:

    private Lock bankLock = new ReentrantLock();
    
    private Condition sufficientFunds=bankLock.newCondition();

    public void transfer(int from, int to, double amount){
        bankLock.lock();
        try{
            while(accounts[from] >amount){
                sufficientFunds.await();
            }
            accounts[from] -= amount;
            System.out.printf(TAG + ":" + " %10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(TAG + ":" + " Total Balance: %10.2f%n", getTotalBalance());
            sufficientFunds.signalAll();
        }catch(InterruptedException e){
            e.printStackTrace();
        }finally{
            bankLock.unlock();
        }
    }

注:调用signalAll方法不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,以便这些线程可以在当前想吃退出同步方法之后,通过竞争实现对对象的访问。

另一个方法signal,则是随机解除等待集中的某个线程的阻塞状态,这比解除所有线程的阻塞更加有效,但也存在危险。如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。此时如果没有其他线程再次调用signal,那么系统就死锁了。

二、synchronized 关键字

在这先总结一下有关锁和条件的关键之处:

* 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码

* 锁可以管理试图进入被保护代码段的线程

* 锁可以拥有一个或多个相关的条件对象

* 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程

从1.0版本就开始,java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获取内容的对象锁。同时每一个对象都有wait()、notify()、notifyAll()三个方法(object类的final方法),此三个方法和Condition的await()、signal()、signalAll()三个方法是对应的。

对应的代码如下:

    public synchronized void transfer(int from, int to, double amount){
        try{
            while(accounts[from] >amount){
               wait();
            }
            accounts[from] -= amount;
            System.out.printf(TAG + ":" + " %10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(TAG + ":" + " Total Balance: %10.2f%n", getTotalBalance());
            notifyAll();
        }catch(InterruptedException e){
            e.printStackTrace();
        }finally{
            bankLock.unlock();
        }
    }

下面我们用网上的一张图看一下synchronized锁的具体对象:

在这看一下内部锁和条件的一些局限,包括:

* 不能中断一个正在试图获得锁的线程

* 试图获得锁时不能设定超时

* 每个锁仅有单一的条件,可能是不够的

在这比较一下Lock/Condition和synchronized:

1、能两者都不用的尽量都不用

2、如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率

3、如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition。

三、读/写锁  ReentrantReadWriteLock

在上面我们说的ReentrantLock类同一个包下,还有一个ReentrantReadWriteLock类。根据名称我们可以理解这是一个读、写相关的锁。如果很多线程从一个数据结构读取数据,而很少线程修改其中数据的话,那么这个类便会很有用。在这种情况下,允许对读取线程共享访问是合适的,写入线程依然是必须是互斥访问的。

1、在此说明一下读取和写入的含义:

读取 没有线程正在做写操作,且没有线程在请求写操作。

写入 没有线程正在做读写操作。

也就是允许读、读操作,不允许读、写操作,不允许写、写操作。

2、总结这个锁机制的特性: 

(a).重入方面其内部的WriteLock可以获取ReadLock,但是反过来ReadLock想要获得WriteLock则永远都不要想。 

 (b).WriteLock可以降级为ReadLock,顺序是:先获得WriteLock再获得ReadLock,然后释放WriteLock,这时候线程将保持Readlock的持有。反过来ReadLock想要升级为WriteLock则不可能,为什么?参看(a)。

(c).ReadLock可以被多个线程持有并且在作用时排斥任何的WriteLock,而WriteLock则是完全的互斥。这一特性最为重要,因为对于高读取频率而相对较低写入的数据结构,使用此类锁同步机制则可以提高并发量。 

(d).不管是ReadLock还是WriteLock都支持Interrupt,语义与ReentrantLock一致。 

(e).WriteLock支持Condition并且与ReentrantLock语义一致,而ReadLock则不能使用Condition,否则抛出UnsupportedOperationException异常。

我们简单看一个使用例子吧:

    ReentrantReadWriteLock rwl=new ReentrantReadWriteLock();
    
    public void get(){
        rwl.readLock().lock();//上读锁,其他线程只能读不能写
        try{
            System.out.println(Thread.currentThread().getName() + " be ready to read data!");
            Thread.sleep((long) (Math.random() * 1000));
            System.out.println(Thread.currentThread().getName() + "have read data :" + data);
        }catch(InterruptedException e){
            e.printStackTrace();
        }finally{
            rwl.readLock().unlock(); //释放读锁,放在finnaly里面
        }
    }

    public void put(Object data){
        rwl.writeLock().lock();//上写锁,不允许其他线程读也不允许写
        try{
            System.out.println(Thread.currentThread().getName() + " be ready to write data!");

            Thread.sleep((long) (Math.random() * 1000));

            this.data = data;
            System.out.println(Thread.currentThread().getName() + " have write data: " + data);
        }catch(InterruptedException e){
            e.printStackTrace();
        }finally{
            rwl.writeLock().unlock();//释放写锁   
        }
    }

 

四、volatile

volatile关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  1)对变量的写操作不依赖于当前值(比如 count += count;就不能使用volatile)

  2)该变量没有包含在具有其他变量的不变式中

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

  事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

  举个小例子吧:

    private volatile boolean done = false;

    public void setDone(boolean done){
        this.done = done;
    }

    public void doSomething(){
        if(done){
            //doSomething
        }
    }

下面这种情况禁止使用:

    public void flipDone(){
        this.done = !done;
    }

五、原子变量

1、首先看一下原子变量的基本概念:

原子变量保证了该变量的所有操作都是原子的,不会因为多线程的同时访问而导致脏数据的读取问题。

2、再看一下原子操作的概念:

原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切 [1]  换到另一个线程)。

java中在java.util.concurent.atomic包中有很多类来保证操作的原子性,如:AtomicInteger、AtomicLong、AtomicBoolean、AtomicLongArray等。

我们这里用AtomicInteger来举例:它的构造函数有:

private volatile int value;

public AtomicInteger(int initialValue) {
    value = initialValue;
}
public AtomicInteger() {

}

也就是可以初始化一个默认值,不给默认值的话就是0。

看一下它的方法:

public final int get()
public final void set(int newValue) 

它也有get和set方法,不过这两个方法不是原子性的。所以一般不要使用。

//基于原子操作,获取当前原子变量中的值并为其设置新值
public final int getAndSet(int newValue)
//基于原子操作,比较当前的value是否等于expect,如果是设置为update并返回true,否则返回false
public final boolean compareAndSet(int expect, int update)
//基于原子操作,获取当前的value值并自增一
public final int getAndIncrement()
//基于原子操作,获取当前的value值并自减一
public final int getAndDecrement()
//基于原子操作,获取当前的value值并为value加上delta
public final int getAndAdd(int delta)
//还有一些反向的方法,比如:先自增在获取值的等等

上面的这些方法都是原子性的,这也是我们使用原子变量需要用到的对应方法,在上面的这些方法操作中都不会产生中断,保证数据的正确性,比如getAndDecrement,是以原子方式将AtomicInteger自增,并返回自增后的值,也就是说,获取值、增1并设置然后生成新增的操作不会中断。可以保证即使是多个线程并发地访问同一个实例,也会计算并返回正确的值。

不过如果有大量的线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。java 8 提供了LongAdder和LongAccumulator类来解决这个问题。简单使用方法为:addr.increment()--递增,addr.sum()--求和。。

具体的使用及原理就不说了,需要了解的可以百度一下就可以了,下面引用一句其他博客里面的简单说明:

大家对AtomicInteger的基本实现机制应该比较了解,它们是在一个死循环内,不断尝试修改目标值,知道修改成功,如果竞争不激烈,那么修改成功的概率就很高,否则,修改失败的概率就很高,在大量修改失败时,这些原子操作就会进行多次循环尝试,因此性能就会受到影响

     那么竞争激烈的时候,我们应该如何进一步提高系统性能呢?一种基本方案就是可以使用热点分离,将竞争的数据进行分解.基于这个思路,打击应该可以想到一种对传统AtomicInteger等原子类的改进方法,虽然在CAS操作中没有锁,但是像减少锁粒度这种分离热点的思路依然可以使用,一种可行的方案就是仿造ConcurrengHashMap,将热点数据分离,比如,可以将AtomicInteger的内部核心数据value分离成一个数组,每个线程访问时,通过哈希等算法映射到其中一个数字进行计数,而最终的计数结果,则为这个数组的求和累加,其中,热点数据value被分离成多个单元cell,每个cell独自维护内部的值,当前对象的实际值由所有的cell累计合成,这样,热点就进行了有效的分离,提高了并行度,LongAdder正是使用了这种思想。

 

java中的主要一些自带同步方法也就上面几种了,不过这里只是简单介绍了下,并没有详细说明原理的深入使用,仅作为一个了解吧,另外一些说明解释可能是参考其他文章的。。。

你可能感兴趣的:(Android,java,同步锁)