并发中的同步(2)

5. 同步

上节说到,在大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取。如果两个线程存取同一个对象,并且每个线程分别调用了一个修改该对象状态的方法,会发生什么呢?可以想见,这两个线程会相互覆盖。这取决于线程访问数据的次序,可能会导致对象被破坏。这种情况通常称为竟态条件(race condition)。

5.1 非同步程序测试

import java.util.Random;

public class UnsynchBankTest {
    public static final int NACCOUNTS = 10;
    public static final double INITIAL_BALANCE = 1000;
    public static final double MAX_AMOUNT = 1000;
    public static final int DELAY = 10;

    public static void main(String[] args) {
        var bank=new Bank(NACCOUNTS,INITIAL_BALANCE);
        for(int i=0;i<NACCOUNTS;i++)
        {
            int fromAccount=i;
            Runnable r=()->{
                try {
                    while (true)
                    {
                        int toAccount=(int) (bank.size()* Math.random());
                        double amount=MAX_AMOUNT*Math.random();
                        bank.transfer(fromAccount,toAccount,amount);
                        Thread.sleep((int)(DELAY*Math.random()));
                    }
                }catch (InterruptedException e)
                {
            }
        };
            var t=new Thread(r);
            t.start();
    }
}
}

在这里插入图片描述

可以清晰的看到,原总金额为10000,经过几次转账后余额发生了变化

5.2 竟态条件详解

上一节中运行了一个程序,其中有几个线程会更新银行账户余额。一段时间之后,不知不觉地出现了错误,可能有些钱会丢失,也可能几个账户同时有钱进账。当两个线程试图同时更新同一个账户时,就会出现这个问题。假设两个线程同时执行指令
accounts[to]+= amount;
问题在于这不是原子操作。这个指令可能如下处理:
1.将accounts[to]加载到寄存器。
2.增加 amount。
3.将结果写回accounts[to]。
现在,假定第1个线程执行步骤1和2,然后,它的运行权被抢占。再假设第2个线程被唤醒,更新acount数组中的同一个元素。然后,第1个线程被唤醒并完成其第3步。
这个动作会抹去第2个线程所做的更新。这样一来,总金额就不再正确了

出现这种破坏的可能性有多大呢?在一个有多个内核的现代处理器上,出问题的风险相当高。我们将打印语句和更新余额的语句交错执行,以提高观察到这种问题的概率。
如果删除打印语句,出问题的风险会降低,因为每个线程在再次休眠之前所做的工作很少,调度器不太可能在线程的计算过程中抢占它的运行权。但是,产生破坏的风险并没有完全消失。如果在负载很重的机器上运行大量线程,那么,即使删除了打印语句,程序依然会出错。这种错误可能几分钟、几小时或几天后才出现。坦白地说,对程序员而言,最糟糕的事情莫过于这种不定期地出现错误。
真正的问题是transfer方法可能会在执行到中间时被中断。如果能够确保线程失去控制之前方法已经运行完成,那么银行账户对象的状态就不会被破坏。

5.3 锁对象
5.3.1 概述

悲观锁:像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态.
悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像synchronized,不管三七二十一,直接上了锁就操作资源了。

乐观锁:还是像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态.
乐观锁认为竞争不总是会发生,因此它不需要持有锁,将”比较-替换”这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。

5.3.2 几种常见的锁

synchronized 互斥锁(悲观锁,有罪假设)
采用synchronized修饰符实现的同步机制叫做互斥锁机制,它所获得的锁叫做互斥锁。
每个对象都有一个monitor(锁标记),当线程拥有这个锁标记时才能访问这个资源,没有锁标记便进入锁池。任何一个对象系统都会为其创建一个互斥锁,这个锁是为了分配给线程的,防止打断原子操作。每个对象的锁只能分配给一个线程,因此叫做互斥锁。
ReentrantLock 排他锁(重入锁)(悲观锁,有罪假设)
ReentrantLock是排他锁,排他锁在同一时刻仅有一个线程可以进行访问,实际上独占锁是一种相对比较保守的锁策略,在这种情况下任何“读/读”、“读/写”、“写/写”操作都不能同时发生,这在一定程度上降低了吞吐量。然而读操作之间不存在数据竞争问题,如果”读/读”操作能够以共享锁的方式进行,那会进一步提升性能。

ReentrantReadWriteLock 读写锁(乐观锁,无罪假设)
因此引入了ReentrantReadWriteLock,顾名思义,ReentrantReadWriteLock是Reentrant(可重入)Read(读)Write(写)Lock(锁),我们下面称它为读写锁。
读写锁内部又分为读锁和写锁,读锁可以在没有写锁的时候被多个线程同时持有,写锁是独占的。
读锁和写锁分离从而提升程序性能,读写锁主要应用于读多写少的场景。

用锁保护代码块的基本结构如下

mylock.lock(); //一个锁对象
try
{
    cretical section //临界区
}
finally
{
	mylock.unlock();//确保锁在抛出异常的情况下也能释放,否则其他线程将永远阻塞
}

这个结构确保任何时刻只有一个线程进入临界区。一旦一个线程锁定了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们会阻塞,直到原本占有该锁的线程释放这个锁对象后,其他线程中的一个线程将会再次获得同一个锁对象,除这一个线程之外的所有想要访问该代码块的线程将会再次阻塞。

下面使用一个锁来保护Bank类的transfer方法

private Lock bankLock;

public void transfer(int from,int to,double amount) throws InterruptedException {
        bankLock.lock();//当前线程获得对象锁
        try {
            System.out.println(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf(" %10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
        }finally {
            bankLock.unlock();//当前线程释放锁
        }
    }

假设一个线程调用了transfer,但是在执行结束前被抢占。再假设第二个线程也调用transfer,由于第二个线程不能获得锁,将在调用lock方法时被阻塞。它会暂停,必须等待第一个线程执行完transfer方法。当第一个线程释放锁时,第二个线程才能开始运行。
在这里插入图片描述

尝试一下。把加锁代码增加到transfer方法并再次运行程序。这个程序可以一直运行下去,银行余额绝对不会有错误。
注意每个Bank对象都有自己的ReentrantLock对象。(如果两个线程试图访问同一个Bank对象,那么锁可以用来保证串行化访问 不过,如果两个线程访问不同的Bank对象,每个线程会得到不同的锁对象,两个线程都不会阻塞 本该如此,因为线程在操纵不同的Bank实例时,线程之间不会相互影响。
这个锁称为重入(reentrant)锁 因为线程可以反复获得已拥有的锁。锁有一个持有计数(hold count)来跟踪对lock方法的嵌套调用。线程每一次调用lock后都要调用unlock来释放锁。由于这个特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。
例如,transfer方法调用getTotalBalance方法,这也会封锁bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出时,持有计数变回1。当transfer方法退出的时候,持有计数变为0,线程释放锁。

API java.util.concurrent.locks.Lock
void lock()
获得这个锁;如果锁当前被另一个线程占有,则阻塞
void unlock()
释放这个锁
API java.util.concurrent.locks.ReentrantLock
ReentrantLock()
构造一个重入锁,可用来保护临界区
5.4 条件对象

通常,线程进入临界区后却发现只有满足的某个条件之后它才能执行。可以使用一个条件对象来管理那些已经获得了一个锁却不能做有用工作的线程。

在上一节的代码中的transfer代码,假如账户中没有足够的资金,我们需要等待,直到另一个线程向账户中增加了资金。但是,这个线程刚刚获得了对bankLock的排他性访问,因此别的线程没有存款的机会。这里就要引入条件对象。

一个锁对象可以有一个或多个相关联的条件对象。你可以用newCondition方法获得一个条件对象。例如在这里我们建立了一个条件对象来表示“资金充足”条件

class Bank
{
    private Condition sufficientFunds;
    .......
    public Bank()
    {
        ......
        sufficientFunds=bankLock.newConditon();
    }
}

如果transfer方法发现资金不足,它会调用 sufficientFunds.await();

当前线程现在暂停,并放弃锁。这就允许另一个线程执行,我们希望它能增加账户余额。等待获得锁的线程和已经调用了await方法的线程存在本质上的不同。一旦一个线程调用了await方法,它就进入这个条件的等待集(wait set)。当锁可用时,该线程并不会变为可运行状态。实际上,它仍保持非活动状态,直到另一个线程在同一条件上调用signalAlt方法。
当另一个线程完成转账时,它应该调用
sufficientFunds.signalAll();
这个调用会重新激活等待这个条件的所有线程。当这些线程从等待集中移出时,它们再次成为可运行的线程,调度器最终将再次将它们激活。同时,它们会尝试重新进入该对象。一旦锁可用,它们中的某个线程将从await调用返回,得到这个锁,并从之前暂停的地方继续执行。

此时,线程应当再次测试条件。不能保证现在一定满足条件——signalAll方法仅仅是通知等待的线程:现在有可能满足条件,值得再次检查条件。

注: 通常,await调用应该放在如下形式的循环中

while(!(OK to proceed))

​ conditon.await();

最终需要有某个其他线程调用signalAll方法,这一点至关重要。当一个线程调用await时,它没有办法重新自行激活。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁 (deadlock)现象。如果所有其他线程都被阻塞,最后一个活动线程调用了await方法但没有先解除另外某个线程的阻塞,现在这个线程也会阻塞。此时没有线程可以解除其他线程的阻塞状态,程序会永远挂起。
应该什么时候调用signalAll呢?从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll。例如,当一个账户余额发生改变时,就应该再给等待的线程一个机会来检查余额。在这个例子中,完成转账时,我们就会调用signalAll方法。

 public void transfer(int from,int to,double amount) throws InterruptedException {
        bankLock.lock();
        try {
            while (accounts[from] < amount)
                sufficientFunds.await();//新增代码
            System.out.println(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf(" %10.2f from %d to %d", amount, from, to);
            accounts[to] += amount;
            System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
            sufficientFunds.signalAll();//新增代码
        }finally {
            bankLock.unlock();
        }
    }

注: signalAll方法调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁后竞争访问对象。

只有当线程拥有一个条件的锁时,它才能在这个条件上调用await,signalAll,signal方法

API java.util.concurrent.locks.Lock
Condition newCondition()
返回一个与这个锁相关联的条件对象
API java.util.concurrent.locks.Condition
void await()
将该线程放在这个条件的等待集中
void signalAll()
解除该条件等待集中的所有线程的阻塞状态
void signal()
从该条件的等待集中随机选择一个线程,解除其阻塞状态
5.5 synchronized关键字
5.5.1 概述

在前面的小节中,我们了解了如何使用Lock和Condition对象。在进一步深入之前,先对锁和条件的要点做一个总结。

  • 锁可以用来保护代码片段,一次只能有一个线程执行被保护的代码
  • 锁可以管理试图进入被保护代码段的线程
  • 一个锁可以有一个或多个相关联的条件对象
  • 每个条件对象管理那些已经进入被保护代码段但还不能运行的线程

Lock和Condition接口允许程序员充分控制锁定。不过大多数情况下,你并不需要那样控制,完全可以使用java语言内部的一种机制。从1.0版本开始,Java中的每个对象都有一个内部锁。如果一个方法声明时有synchronized关键字,那么对象的锁将保护整个方法。也就是说要调用这个方法,线程必须获得内部对象锁。

public synchronized void method()
{
method body
}

等价于

public void method()
{
this.intrinsicLock.lock();
try{
method body
}
finally{
this.intrinsicLock.unlock();
}
}

内部对象锁只有一个关联条件。其中,wait()等价于intrinsicCondition.await(),notifyAll()等价于intrinsicCondition.signalAll().

5.5.2 特点
  1. synchronized同步关键字可以用来修饰代码块,称为同步代码块,使用的锁对象类型任意,但注意:必须唯一!
  2. synchronized同步关键字可以用来修饰方法,称为同步方法
  3. 同步的缺点是会降低程序的执行效率,但我们为了保证线程的安全,有些性能是必须要牺牲的

例如,可以用Java如下实现Bank类

public synchronized void transfer(int from,int to,double amount) throws InterruptedException {
        while (accounts[from] < amount) {
            wait();//等价于 sufficientFunds.await();
        }
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf(" %10.2f from %d to %d", amount, from, to);
        accounts[to] += amount;
        System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
        notifyAll();//等价于 sufficientFunds.signalAll();//新增代码
    }

可以看到,使用synchronized关键字能够得到更为简洁的代码。

5.5.4 限制

内部锁和条件存在一些限制,包括:

  • 不能中断一个正在尝试获得锁的线程
  • 不能指定尝试获得锁时的超时时间
  • 每个锁仅有一个条件可能是不够的

在代码中我们应该使用Lock和Condition对象还是synchronized同步方法?

  • 最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下,我们可以使用阻塞队列来同步完成一个共同任务的线程
  • 如果synchronized关键字适合你的程序,那么尽量使用这种方法,既减少了代码的编写量,也不容易出错
  • 如果确实特别需要Lock/Condition的额外能力,则使用它

import java.util.Arrays;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bank {
    private final double[] accounts;
    private Lock bankLock;
    private Condition sufficientFunds;
    /**
     * Constructs the bank
     * @param n the number of accounts
     * @param initialBalance the initial balance for each account
     */
    public Bank(int n,double initialBalance)
    {
        accounts=new double[n];
        Arrays.fill(accounts,initialBalance);
        bankLock=new ReentrantLock();
        sufficientFunds=bankLock.newCondition();
    }

    /**
     * Transfers money from one account to another
     * @param from the account to transfer from
     * @param to to the account to transfer to
     * @param amount the amount to transfer
     */
    public synchronized void transfer(int from,int to,double amount) throws InterruptedException {
        while (accounts[from] < amount) {
            wait();
        }
        System.out.println(Thread.currentThread());
        accounts[from] -= amount;
        System.out.printf(" %10.2f from %d to %d", amount, from, to);
        accounts[to] += amount;
        System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
        notifyAll();
    }
//    public void transfer(int from,int to,double amount) throws InterruptedException {
//        bankLock.lock();
//        try {
//            while (accounts[from] < amount)
//                sufficientFunds.await();
//            System.out.println(Thread.currentThread());
//            accounts[from] -= amount;
//            System.out.printf(" %10.2f from %d to %d", amount, from, to);
//            accounts[to] += amount;
//            System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
//            sufficientFunds.signalAll();
//        }finally {
//            bankLock.unlock();
//        }
//    }

    /**
     * Gets the sum of all account balance
     * @return the total balance
     */
    public synchronized double getTotalBalance()
    {
        double sum = 0;
        for (double a : accounts)
            sum += a;
        return sum;

    }

    public synchronized void getEachBalance()
    {

        for (int i=0;i<size();i++)
        {
            System.out.println(accounts[i]);
        }
    }

    /**
     * Gets the number of accounts in the bank
     * @return the number of accounts
     */
    public int size()
    {
        return accounts.length;
    }
}

API java.lang.Object
void notifyAll()
解除在这个对象上调用wait方法的那些线程的阻塞状态。该方法只能在同步方法或同步块内调用。如果当前线程不是该对象内部锁的持有者,则抛出IllgalMonitorStateException异常
void notify()
随机选择在这个对象上调用wait方法的那些线程中的一个,并解除其阻塞状态。该方法只能在同步方法或同步块内调用。如果当前线程不是该对象内部锁的持有者,则抛出IllgalMonitorStateException异常
void wait()
导致一个线程进入等待状态,直到它得到通知。该方法只能在同步方法或同步块内调用。如果当前线程不是该对象内部锁的持有者,则抛出IllgalMonitorStateException异常
void wait(long mills)
void wait(long mills,int nanos)
导致一个线程进入等待状态,直到它得到通知或者经过了指定的世纪。该方法只能在同步方法或同步块内调用。如果当前线程不是该对象内部锁的持有者,则抛出IllgalMonitorStateException异常。纳秒数不能超过1000000
5.5.5 同步块

每一个Java对象都有一个内部锁。线程可以通过调用同步方法获得锁,也可以进入一个同步块来获得锁。

synchronized(obj)//获得对象obj的锁

{

critical section //临界区

}

5.5.6 死锁

锁和条件不能解决多线程中可能出现的所有问题。考虑下面的情况:
1.账户1:$200
2.账户2:$300
3.线程1:从账户1转$300到账户2
4.线程2:从账户 2 转 $400 到账户 1
线程1和线程2显然都被阻塞。因为账户1以及账户2中的余额都不足以进行转账,两个线程都无法执行下去。

有可能会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞。这样的状态称为死锁 (deadlock)。

在这个程序里,死锁不会发生,原因很简单。每一次转账至多$1000。因为总共有100个账户,而且所有账户的总金额是$100000,在任意时刻,至少有一个账户的余额高于$1000。从该账户转账的线程可以继续运行。

但是,如果修改run方法,把每次转账至多$1000的限制去掉,很快就会发生死锁。将NACCOUNTS设为10。每次交易的金额上限max值设置为2*INITIAL_BALANCE,然后运行该程序。程序运行一段时间后就会挂起。

还有一种做法会导致死锁,让第1个线程负责向第1个账户存钱,而不是从第1个账户取 也钱。这样一来,有可能所有线程都集中到一个账户上,每一个线程都试图从这个账户中取出大于该账户余额的钱。在程序中,转用TransferRunnable类的run方法。在transfer调用中,交换fromAccount和toAccount。运行该程序,会看到它几乎会立即死锁。

还有一种很容易导致死锁的情况:在程序中,将signalAll方法修改为signal方法,会发现该程序最终会挂起。(同样,将NACCOUNTS设为10可以更快地看到结果)。signaAll方法会通知所有等待增加资金的线程,与此不同,signal方法只解除一个线程的阻塞。如果该线程不能继续运行,所有的线程都会阻塞。

考虑下面这种可能发生死锁的情况。
1.账户1:$1990
2.所有其他账户:分别有$990
3.线程1:从账户1转$995到账户2
4.所有其他线程:从它们的账户转$995到另一个账户

显然,除了线程1,所有的线程都被阻塞,因为它们的账户中没有足够的金额。线程1继续执行,现在情况如下:
1.账户 1:$995
2.账户 2:$985
3.所有其他账户:分别有$990

然后,线程1调用signal方法。signal方法随机选择一个线程将它解除阻塞。假定它选择了线程3。该线程被唤醒,发现在它的账户里没有足够的金额,它再次调用await。但是,线程1仍在运行,将随机地产生一个新的交易,例如,
1.线程1:从账户1转$997到账户2

现在,线程1也调用await,所有的线程都被阻塞。系统死锁。
问题的起因在于signal调用。它只为一个线程解除阻塞,而且,它很可能选择一个根本不能继续运行的线程(在我们的例子中,线程2必须从账户2中取钱)。
遗憾的是,Java编程语言中没有任何东西可以避免或打破这种死锁。必须仔细设计程序,确保不会出现死锁。

5.6 小结

如果多个线程要并发地修改一个数据结构,例如散列表,那么很容易破坏这个数据结构。例如,一个线程可能开始向表中插入一个新元素。假定在调整散列表各个桶之间的链接关系的过程中,这个线程的控制权被抢占。如果另一个线程开始遍历同一个链表,可能使用无效的链接并造成混乱,可能会抛出异常或者陷入无限循环。
可以通过提供锁来保护共享的数据结构,但是选择线程安全的实现可能更为容易。在下一篇文章中,将讨论Java类库提供的另外一些线程安全的集合。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值