Java多线程之“同步”

时间:2022-06-03 18:29:21

好习惯要坚持,这是我第二篇博文,任务略重,但是要坚持努力!!!

1.竞争条件

首先,我们回顾一下《Java核心技术卷》里讲到的多线程的“竞争条件”。由于各线程访问数据的次序,可能会产生讹误的现象,这样一个情况通常称为“竞争条件”。

那么,讹误具体是怎么产生的呢?本质上,是由于操作的非原子性。比如,假定两个线程同时执行指令 account[to] += amount;该指令可能会被处理如下:

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

2)增加amount[to]。

3)将结果写回account[to]。

现在,假定第一个线程执行步骤1和2,然后,它被剥夺了运行权。假定第二个线程被唤醒并修改了accounts数组中的同一项。然后,第1个线程被唤醒并完成第3步。这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。

---------------------------------------------我是分割线---------------------------------------------------------------------------------------------

好,我们再从java的内存模型来深层次讲讲“讹误”,这里有个概念叫做“缓存一致性”。

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

也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

i = i + 1;

 当线程执行这个语句时,会先从主存当中读取i的值,然后复制一份到高速缓存当中,然后CPU执行指令对i进行加1操作,然后将数据写入高速缓存,最后将高速缓存中i最新的值刷新到主存当中。这个代码在单线程中运行是没有任何问题的,但是在多线程中运行就会有问题了。在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存(对单核CPU来说,其实也会出现这种问题,只不过是以线程调度的形式来分别执行的)。本文我们以多核CPU为例。比如同时有2个线程执行这段代码,假如初始时i的值为0,那么我们希望两个线程执行完之后i的值变为2。但是事实会是这样吗?

可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作之后,i的值为1,然后线程2把i的值写入内存。最终结果i的值是1,而不是2。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

  为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK#锁的方式

  2)通过缓存一致性协议

  这2种方式都是硬件层面上提供的方式。

  在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

关于java内存模型,我有空会单独写一篇文章进行总结,这里仅仅浅谈一下。下面,我们再来谈谈锁对象条件对象synchronized关键字

2.锁对象

有两种机制防止代码块受并发访问的干扰。Java语言提供了一个synchronized关键字达到这一目的,并且JavaSE 5.0引入了ReentrantLock类。

我们先看看ReentrantLock:

java.util.concurrent.locks.ReentrantLock  5.0 已实现的接口:Serializable, Lock

我们再来看看Lock接口: java.util.concurrent.locks.Lock 5.0,该接口下有2个方法:

(1) void lock() 获取这个锁:如果锁同时被另一个线程拥有则发生阻塞。

(2)void unlock() 释放这个锁。

让我们使用一个锁来保护Bank类的transfer方法。下面我们来看看3个类:

Java多线程之“同步”Java多线程之“同步”
 1 package unsynch;
2
3 import java.util.concurrent.locks.Lock;
4 import java.util.concurrent.locks.ReentrantLock;
5
6 /**
7 * A bank with a number of bank accounts.
8 * @version 1.30 2004-08-01
9 * @author Cay Horstmann
10 */
11 public class Bank
12 {
13 private final double[] accounts;
14 private Lock bankLock = new ReentrantLock();
15 /**
16 * Constructs the bank.
17 * @param n the number of accounts
18 * @param initialBalance the initial balance for each account
19 */
20 public Bank(int n, double initialBalance)
21 {
22 accounts = new double[n];
23 for (int i = 0; i < accounts.length; i++)
24 accounts[i] = initialBalance;
25 }
26
27 /**
28 * Transfers money from one account to another.
29 * @param from the account to transfer from
30 * @param to the account to transfer to
31 * @param amount the amount to transfer
32 */
33 public void transfer(int from, int to, double amount)
34 {
35 bankLock.lock();
36 try{
37 if (accounts[from] < amount) return;
38 System.out.print(Thread.currentThread());
39 accounts[from] -= amount;
40 System.out.printf(" %10.2f from %d to %d", amount, from, to);
41 accounts[to] += amount;
42 System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
43 }
44 finally{
45 bankLock.unlock();
46 }
47
48 }
49
50 /**
51 * Gets the sum of all account balances.
52 * @return the total balance
53 */
54 public double getTotalBalance()
55 {
56 double sum = 0;
57
58 for (double a : accounts)
59 sum += a;
60
61 return sum;
62 }
63
64 /**
65 * Gets the number of accounts in the bank.
66 * @return the number of accounts
67 */
68 public int size()
69 {
70 return accounts.length;
71 }
72 }
View Code
Java多线程之“同步”Java多线程之“同步”
 1 package unsynch;
2
3 /**
4 * A runnable that transfers money from an account to other accounts in a bank.
5 * @version 1.30 2004-08-01
6 * @author Cay Horstmann
7 */
8 public class TransferRunnable implements Runnable
9 {
10 private Bank bank;
11 private int fromAccount;
12 private double maxAmount;
13 private int DELAY = 10;
14
15 /**
16 * Constructs a transfer runnable.
17 * @param b the bank between whose account money is transferred
18 * @param from the account to transfer money from
19 * @param max the maximum amount of money in each transfer
20 */
21 public TransferRunnable(Bank b, int from, double max)
22 {
23 bank = b;
24 fromAccount = from;
25 maxAmount = max;
26 }
27
28 public void run()
29 {
30 try
31 {
32 while (true)
33 {
34 int toAccount = (int) (bank.size() * Math.random());
35 double amount = maxAmount * Math.random();
36 bank.transfer(fromAccount, toAccount, amount);
37 Thread.sleep((int) (DELAY * Math.random()));
38 }
39 }
40 catch (InterruptedException e)
41 {
42 }
43 }
44 }
View Code
Java多线程之“同步”Java多线程之“同步”
 1 package unsynch;
2
3 /**
4 * This program shows data corruption when multiple threads access a data structure.
5 * @version 1.30 2004-08-01
6 * @author Cay Horstmann
7 */
8 public class UnsynchBankTest
9 {
10 public static final int NACCOUNTS = 100;
11 public static final double INITIAL_BALANCE = 1000;
12
13 public static void main(String[] args)
14 {
15 Bank b = new Bank(NACCOUNTS, INITIAL_BALANCE);
16 int i;
17 for (i = 0; i < NACCOUNTS; i++)
18 {
19 TransferRunnable r = new TransferRunnable(b, i, INITIAL_BALANCE);
20 Thread t = new Thread(r);
21 t.start();
22 }
23 }
24 }
View Code

 这段程序模拟一个有若干账户的银行。随机地生成在这些账户之间转移钱款的交易。每一个账户有一个线程。每一笔交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。尝试一下,添加加锁代码到transfer方法并且再次运行程序,你永远可以运行它,而银行的余额不会出现讹误。

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

注意每一个Bank对象有自己的ReentrantLock对象。如果两个线程试图访问同一个Bank对象,那么锁以串行方式提供服务。但是,如果两个线程访问不同的Bank对象,每一个线程得到不同的锁对象,两个线程都不会发生阻塞。本该如此,因为线程在操纵不同的Bank实例的时候,线程之间不会互相影响。

锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数(hhldcount)来跟踪对lock方法的嵌套调用。线程在每一次调用lock都要调用unlock来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同锁的方法。例如,transfer方法调用getTotalBalance方法,这也会*bankLock对象,此时bankLock对象的持有计数为2。当getTotalBalance方法退出的时候,持有计数变回1。当transfer方法退出的时候,持有计数变为0。线程释放锁。

警告:把解锁操作括在finally子句之内是至关重要的。如果临界区的代码抛出异常,锁必须被释放。否则,其它线程将永远被阻塞。

3.条件对象

条件对象经常被称为条件变量。一个锁对象可以有一个或多个相关的条件对象。你可以用newCondition方法获得一个条件对象。

下面我们再来看看Java API 中的Conditon接口的定义:java.util.concurrent.locks.Condition 5.0,它有几个方法:

void await() 将该线程放到条件的等待集中。

void signalAll() 解除该条件的等待集中的所有线程的阻塞状态(通过竞争)

void signal()  从该条件的等待集中随机地选择一个线程,解除其阻塞状态。

下面我们通过一个代码的示例来说明这个条件对象如何使用:

Java多线程之“同步”Java多线程之“同步”
 1 package synch;
2
3 import java.util.concurrent.locks.*;
4
5 /**
6 * A bank with a number of bank accounts that uses locks for serializing access.
7 * @version 1.30 2004-08-01
8 * @author Cay Horstmann
9 */
10 public class Bank
11 {
12 private final double[] accounts;
13 private Lock bankLock;
14 private Condition sufficientFunds;
15
16 /**
17 * Constructs the bank.
18 * @param n the number of accounts
19 * @param initialBalance the initial balance for each account
20 */
21 public Bank(int n, double initialBalance)
22 {
23 accounts = new double[n];
24 for (int i = 0; i < accounts.length; i++)
25 accounts[i] = initialBalance;
26 bankLock = new ReentrantLock();
27 sufficientFunds = bankLock.newCondition();
28 }
29
30 /**
31 * Transfers money from one account to another.
32 * @param from the account to transfer from
33 * @param to the account to transfer to
34 * @param amount the amount to transfer
35 */
36 public void transfer(int from, int to, double amount) throws InterruptedException
37 {
38 bankLock.lock();
39 try
40 {
41 while (accounts[from] < amount)
42 sufficientFunds.await();
43 System.out.print(Thread.currentThread());
44 accounts[from] -= amount;
45 System.out.printf(" %10.2f from %d to %d", amount, from, to);
46 accounts[to] += amount;
47 System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
48 sufficientFunds.signalAll();
49 }
50 finally
51 {
52 bankLock.unlock();
53 }
54 }
55
56 /**
57 * Gets the sum of all account balances.
58 * @return the total balance
59 */
60 public double getTotalBalance()
61 {
62 bankLock.lock();
63 try
64 {
65 double sum = 0;
66
67 for (double a : accounts)
68 sum += a;
69
70 return sum;
71 }
72 finally
73 {
74 bankLock.unlock();
75 }
76 }
77
78 /**
79 * Gets the number of accounts in the bank.
80 * @return the number of accounts
81 */
82 public int size()
83 {
84 return accounts.length;
85 }
86 }
View Code
Java多线程之“同步”Java多线程之“同步”
 1 package synch;
2
3 /**
4 * This program shows how multiple threads can safely access a data structure.
5 * @version 1.30 2004-08-01
6 * @author Cay Horstmann
7 */
8 public class SynchBankTest
9 {
10 public static final int NACCOUNTS = 100;
11 public static final double INITIAL_BALANCE = 1000;
12
13 public static void main(String[] args)
14 {
15 Bank b = new Bank(NACCOUNTS, INITIAL_BALANCE);
16 int i;
17 for (i = 0; i < NACCOUNTS; i++)
18 {
19 TransferRunnable r = new TransferRunnable(b, i, INITIAL_BALANCE);
20 Thread t = new Thread(r);
21 t.start();
22 }
23 }
24 }
View Code
Java多线程之“同步”Java多线程之“同步”
 1 package synch;
2
3 /**
4 * A runnable that transfers money from an account to other accounts in a bank.
5 * @version 1.30 2004-08-01
6 * @author Cay Horstmann
7 */
8 public class TransferRunnable implements Runnable
9 {
10 private Bank bank;
11 private int fromAccount;
12 private double maxAmount;
13 private int DELAY = 10;
14
15 /**
16 * Constructs a transfer runnable.
17 * @param b the bank between whose account money is transferred
18 * @param from the account to transfer money from
19 * @param max the maximum amount of money in each transfer
20 */
21 public TransferRunnable(Bank b, int from, double max)
22 {
23 bank = b;
24 fromAccount = from;
25 maxAmount = max;
26 }
27
28 public void run()
29 {
30 try
31 {
32 while (true)
33 {
34 int toAccount = (int) (bank.size() * Math.random());
35 double amount = maxAmount * Math.random();
36 bank.transfer(fromAccount, toAccount, amount);
37 Thread.sleep((int) (DELAY * Math.random()));
38 }
39 }
40 catch (InterruptedException e)
41 {
42 }
43 }
44 }
View Code

这段代码显然比上段代码多了一些东西,为什么要多这些东西呢?我们这么做是为了细化银行的模拟程序。我们避免选择没有足够资金的账户作为转出账户。
如果transfer方法发现余额不足,它调用sufficientFunds.await();当前线程现在被阻塞了,并放弃了锁。一旦一个线程调用await方法,它进入该条件的等待集。当锁可用时,该线程不能马上解除阻塞。相反,它处于阻塞状态,直到另一个线程调用同一条件上的signalAll方法时为止。

当另一个线程转账是,它应该调用sufficientFunds.signalAll();

这一调用重新激活因为这一条件而等待的所有线程。当这些线程从等待集当中移出时,它们再次成为可行的,调度器将再次激活它们。同时,它们将试图重新进入该对象。一旦锁成为可用的,它们中的某个将从await调用返回,获得该锁并从被阻塞的地方继续执行。

此时,线程应该再次测试该条件。由于无法确保该条件被满足——signalAll方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。

 最后,有一点需要注意:当一个线程调用await()时,它没有办法重新激活自身。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁(deadlock)现象。总结一下:每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

4.synchronized关键字

大多数情况下,我们并不需要Lock和Condition接口为程序设计人员提供的高度的锁定控制。从1.0版本开始,java中的每一个对象都有一个内部锁。如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。

换句话说,

public synchronized void method()       等价于    public void method()

{                                                                   {

method body                                                       this.intrinsicLock.lock();

}                                                                        try

                                                                               {

                                                                                     method body

                                                                                }

                                                                               finally{this.intrinsicLock.unlock();}

                                                                       }

例如,可以简单地声明Bank类的transfer方法为synchronized,而不是使用一个显式的锁。

同样的,下面我们通过一段代码来理解synchronized关键字。

Java多线程之“同步”Java多线程之“同步”
 1 package synch2;
2
3 /**
4 * A bank with a number of bank accounts that uses synchronization primitives.
5 * @version 1.30 2004-08-01
6 * @author Cay Horstmann
7 */
8 public class Bank
9 {
10 private final double[] accounts;
11
12 /**
13 * Constructs the bank.
14 * @param n the number of accounts
15 * @param initialBalance the initial balance for each account
16 */
17 public Bank(int n, double initialBalance)
18 {
19 accounts = new double[n];
20 for (int i = 0; i < accounts.length; i++)
21 accounts[i] = initialBalance;
22 }
23
24 /**
25 * Transfers money from one account to another.
26 * @param from the account to transfer from
27 * @param to the account to transfer to
28 * @param amount the amount to transfer
29 */
30 public synchronized void transfer(int from, int to, double amount) throws InterruptedException
31 {
32 while (accounts[from] < amount)
33 wait();
34 System.out.print(Thread.currentThread());
35 accounts[from] -= amount;
36 System.out.printf(" %10.2f from %d to %d", amount, from, to);
37 accounts[to] += amount;
38 System.out.printf(" Total Balance: %10.2f%n", getTotalBalance());
39 notifyAll();
40 }
41
42 /**
43 * Gets the sum of all account balances.
44 * @return the total balance
45 */
46 public synchronized double getTotalBalance()
47 {
48 double sum = 0;
49
50 for (double a : accounts)
51 sum += a;
52
53 return sum;
54 }
55
56 /**
57 * Gets the number of accounts in the bank.
58 * @return the number of accounts
59 */
60 public int size()
61 {
62 return accounts.length;
63 }
64 }
View Code

尤其需要注意的是,内部对象锁只有一个相关条件,可能是不够的!在代码中应该使用哪一种?Lock和Condition对象还是同步方法?下面是一些建议。
1)最好既不使用Lock/Condition也不使用synchronized关键字。在许多情况下你可以使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁。

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

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

如上所述,内部对象只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态。换句话说,调用wait或notifyAll等价于

intrinsicCondition.await();

intrinsicCondition.signalAll();

注释:wait,notifyAll以及notify方法是Object类的final方法。Condition方法必须被命名为await,signalAll和signal以便它们不会与那些方法发生冲突。

下面,我们再来看看java.lang.Object内的几个相关方法:

  • void notifyAll()

解除那些在该对象上调用wait方法的线程的阻塞状态。该方法只能在同步方法或同步块内部调用。如果当前线程不是对象锁的持有者,该方法抛出一IllegalMonitorStateException异常。

  • void notify()

随机选择一个在该对象上调用wait方法的线程,解除其阻塞状态。该方法只能在一个同步方法或同步块中调用,如果当前线程不是对象锁的持有者,该方法抛出一个IllgalMonitorStateException异常。

  • void wait()

导致线程进入等待状态只到它被通知。该方法只能在一个同步方法中调用。如果当前线程不是对象锁的持有者,该方法抛出一个IllegalMonitorStateException异常。

  • void wait(long millis)
  • void wait(long millis,int nanos)

需要尤其注意以上红色字体部分。这里有2层意思:(1)这意味着,使用wait(),notifyAll(),notify()时,必须使用synchronized关键字!(2)然而,使用synchronized时,未必会用wait()等方法。synchronized方法只是让线程排队,就是同步代码块,但是排队后一个线程获得内部锁后,未必就满足继续执行下去的条件!所以,考虑到余额不足时要阻塞,就必须使用wait(),如果要考虑多个条件,则要考虑使用Lock/Conditon了。

5.同步阻塞

每一个java对象有一个锁,线程可以通过调用同步方法获得锁,还有一种机制可以获得锁,通过进入一个同步阻塞,即同步块!我们有时会遇到如下“特殊的”锁,例如:

public class Bank

{

    private double [] accounts;

    private Object lock = new Object();

    ...

    public void transfer (int from,int to,int amount)

     {

        synchronized(lock)

         {

            accounts[from] -=amount;

            accounts[to] += amount;

         }

      System.out.println(...);

     }

}

在此,lock对象被创建仅仅是用来使用每个java对象持有的锁。程序猿使用一个对象的锁来实现额外的原子操作,实际上成为客户端锁定

客户端锁定是非常脆弱的,通常不推荐使用。

----------------------------------------------我是分割线-------------------------------------------------

到这里,同步第一部分讲完了。写这一部分整整用了我三个晚上!确实,写博客是个慢工夫,但是印象深刻,脉络清晰。看着《java核心技术卷》厚厚一本,而我进度如蜗牛!还有很多事要做,特别忙,真是心急如焚。这是个非常蛋疼的问题,然而不积跬步无以至千里,这是作为一个优秀程序猿的必经之路,望君加油!