Java 并发 线程同步

时间:2024-04-15 20:37:34

Java 并发 线程同步

@author ixenos

同步


1.异步线程本身包含了执行时需要的数据和方法,不需要外部提供的资源和方法,在执行时也不关心与其并发执行的其他线程的状态和行为

2.然而,大多数实际的多线程应用中,两个或两个以上的线程需要共享对同一数据的存取,这将产生同步问题(可见性和同步性的丢失)

  比如两个线程同时执行指令account[to] += amount,这不是原子操作,可能被处理如下:

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

  b)增加amount

  c)将结果写回account[to]

  还可以通过javap -c -v Bank对Bank.class文件进行反编译,将得到以下字节码:

   aload_0

   getfield    #2; //Field accounts:[D

   iload_2

   dup2

   daload

   dload_3

   dadd

   dastore

  执行他们的线程可以在任何一条指令点上被中断,多线程执行就会产生同步问题

对象锁


0.在任何时刻,一个对象的对象锁至多只能被一个线程拥有

1.两种机制防止代码块受并发访问干扰

  a)synchronized关键字(synchronized关键字自动提供了一个锁和相关的条件)、ReentrantLock类

  b)java.util.concurrent 框架提供的独立的类

2.可重入锁

  1)Java运行系统允许一个线程重复获得已持有的对象锁,锁的可重入性可以防止一个线程的死锁

   2)synchronized和ReentrantLock都实现了可重入锁(ReentrantLock是Lock接口的实现类,但Lock接口本身不定义可重入锁!)

  3)锁保持一个持有计数(hold count)来跟踪锁的重入,当持有计数变为0的时候,线程才释放锁

    Q:那为什么要设定成对同一线程可重入锁,而不放锁呢?

    A:因为可能要保护某一片需若干个操作来更新的代码块,要确保这些操作完成后,另一个线程才能使用相同的对象

ReentrantLock保护代码块的基本结构如下:

 myLock.lock(); //myLock是一个ReentrantLock对象
try{
critical condition
}finally{
myLock.unlock(); //放在finally里即使抛出异常都释放锁
} ReentrantLock可重入锁情况:
 public class Bank{
private Lock banklock = new ReentrantLock(); //ReentrantLock实现了Lock接口
...
public void transfer(int from, int to, int amount){
bankLock.lock();
try{
System.out.print(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()); //getTotalBalance()也是同步方法时,在同一线程内部锁可以重入
}finally{
bankLock.unlock();
}
}
}

  注意: 1)把解锁操作放在finally子句中是至关重要的,因为如果临界区的代码抛出异常,锁必须释放,否则其他线程将永远阻塞!

       2)注意不能使用try-with-resource语句,首先ReentrantLock类并没有实现Closeable接口,其次是因为解锁方法名不是close,即使改成close也不能工作,因为try-with-resource希望声明的是一个新变量,而显然我们使用锁时,是为了让多个线程交替持有锁。

synchronized保护代码块的基本结构如下:

 synchronized{
critical condition
}
//synchronized过了临界区自动释放锁 --------------------------------------------- public class Box{
private int value;
public synchronized void put(int value){ //方法锁定对象,该对象其他同步方法也被锁定!
this.value=value;
}
public synchronized int get(){
return this.value;
}
}

  synchronized可重入锁

 public class Reentrant{
public synchronized void a(){
b();
System.out.println("method a() is called");
}
public synchronized void b(){
System.out.println("method b() is called");
}
} ---------
输出:
method b() is called //说明该线程可以再次取得该对象锁(可重入锁)
method a() is called

接口 java.util.concurrent.locks.Lock 定义了:

  void lock();

  void unlock;

可重入锁类 java.util.concurrent.locks.ReentrantLock 定义了:

  ReentrantLock(); //构建一个可以被用来保护临界区的可重入锁

  ReentrantLock(boolean fair);  //构建一个带有公平策略的锁,优先放锁给等待时间最长的线程,公平锁因此降低程序性能

    注意:公平锁也无法确保线程调度器是公平的,调度器想忽略谁就忽略谁

条件对象


1.使用场景:线程进入临界区,却发现只有在某一条件满足之后它才能执行,要使用一个条件对象来管理那些已经获得一个锁却不能做有用工作的线程

2.代码示例:

  还是银行的存取问题

 if(bank.getBalance(from) >= amount){ //判断余额是否足够
//当前线程完全可能完成if条件测试后,且在调用transfer前就被中断了!
bank.transfer(from, to, amount);
}

  为此我们把余额判断和转账锁定成原子操作

 public void transfer(int from, int to, int amount){
bankLock.lock();
try{
while(account[from] < amount){ //检查余额是否足够取出
//wait
...
}
//transfer funds
...
}finally{
bankLock.unlock();
}
}

  但是问题来了:当账户中没有足够的金额时,就只能等待其它线程向账户注入资金,但是这线程刚取得了bankLock的排他性访问,因此别的线程没有进行存款操作的机会,此时就需要使用条件对象

  • 一个锁对象可以有一个或多个相关的条件对象
  • 使用锁对象的newCondition方法来生成一个Condition对象,一般使其命名为它所表达条件的名字
 class Bank{
private Condition sufficientFunds;
...
public Bank(){
...
sufficientFunds = bankLock.newCondition();
}
}

   1) 此时如果对象余额不足,就可调用sufficientFunds.await();使当前线程被阻塞,并放弃锁,等待其他线程完成任务并调用signalAll激活该线程(无法自我激活)

  2) sufficientFunds.signalAll(); 这一调用将重新激活因为这一条件而等待的所有线程为Runnable状态(这是抽象的条件,具体的条件是我们编写的配合await方法的判断语句)

  3) 如果没有人来激活,将导致死锁;如果所有其他线程都阻塞了,最后一个线程也先执行await,那么就全阻塞了,无药可救,程序就挂起了

  4) 另一个方法signal是随机接触某个线程的阻塞状态,这更高效也更危险,因为如果接触后还是不能运行,那么它将再次阻塞,没有其他人再执行signal时,下场就跟3)一样

综合示例:

 import java.util.concurrent.locks.*;

 /**
* A bank with a number of bank accounts that uses locks for serializing access.
* @version 1.30 2004-08-01
* @author Cay Horstmann
*/
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];
for (int i = 0; i < accounts.length; i++)
accounts[i] = initialBalance;
bankLock = new ReentrantLock();
sufficientFunds = bankLock.newCondition();
} /**
* Transfers money from one account to another.
* @param from the account to transfer from
* @param to the account to transfer to
* @param amount the amount to transfer
*/
public void transfer(int from, int to, double amount) throws InterruptedException
{
bankLock.lock(); //带有ReentrantLock
try
{
while (accounts[from] < amount)
sufficientFunds.await(); //条件对象阻塞放锁,让其他线程注入资金
System.out.print(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 balances.
* @return the total balance
*/
public double getTotalBalance()
{
bankLock.lock(); //带有ReentrantLock
try
{
double sum = 0; for (double a : accounts)
sum += a; return sum;
}
finally
{
bankLock.unlock();
}
} /**
* Gets the number of accounts in the bank.
* @return the number of accounts
*/
public int size()
{
return accounts.length;
}
}

总结:

锁和条件的关键之处:

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

2.锁可以用来管理试图进入被保护代码段的线程

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

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

synchronized关键字


  • synchronized关键字利用了对象的内部锁内部条件来实现同步锁定和释放
  • synchronized两种形式:
    • 同步方法(synchronized method)
    • 同步块(synchronized block)
      • 以下示例以同步方法为主,本节最后再谈及同步块

  Lock和Condition接口提供了高度的锁定控制,但通常我们不需要

  • 从1.0版开始,Java中的对象都有一个内部锁(注意不是ReentrantLock),如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法,要调用该方法,线程必须获得对象的内部锁

所以

public synchronized void method(){
...
}

等价于

public void method(){
this.intrinsticLock.lock();
try{
...
}finally{
this.intrinsticLock.unlock();
}
}
  • 内部对象锁只持有一个相关内部条件,我们使用wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态,所以调用wait/notifyAll相当于
intrinsticLock.await();
intrinsticLock.signalAll();

  作用是相同的,但wait,notifyAll,notify是Object类的final方法,为了避免冲突,Condition命名时就是await,signalAll,signal,其实前者的名字更恰当!

例如

 class Bank{
private double[] accounts;
//改用synchronized关键字,调用内部锁
public synchronized void transfer(int from, int to, int amount){
while(account[from] < amount){
wait();
}
accounts[from] -= amount;
accounts[to] += amount;
notifyAll();
} //synchronized标注的方法执行完毕,内部锁自动释放 public synchronized double getTotalBalance(){...}
} /*
使用synchronized时必须要了解,
每一个对象都有一个内部锁,并且该锁有一个内部条件。
synchronized只是个关键字标记,实际上
由内部锁来管理那些试图进入synchronized方法的线程,
由内部条件来管理那些调用wait的线程 */
  • 静态方法声明为synchronized也是合法的,这样该方法将获得相关类对象内部锁(不要忘了类对象!!!)
  • 内部锁和内部条件的局限性:
    • 不能中断一个正在试图获得锁的线程
    • 试图获得锁时不能设定超时
    • 每个锁仅有单一条件
  • 那么该用外部锁和条件,还是内部锁和条件呢?
    • 首选java.util.concurrent包中的相关机制(阻塞队列等),会为你处理所有的加锁
    • 如果synchronized很适合,就使用它
    • 需要Lock/Condition的独有特性时,才使用它
      • 即concurrent > synchronized > Lock/Condition
  • 同步块(synchronized block)

  简单示例:

 synchronized(obj){     // obj作为该同步块的锁对象,只有持有该对象的锁才能进入代码块
critical section
}

  完整示例:

 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;
} //释放对象锁
}
}

   客户端锁定:使用一个实际对象的锁来实现额外的原子操作,称为客户端锁定(clientside locking)

 /*
显然该方法不是原子操作,线程并发访问时将存在同步问题
*/
public void transfer(Vector<Double> accounts, int from, int to, int amount){
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
...
} -------------------------- /*
使用同步块使该方法关键操作变成原子操作
*/
public void transfer(Vector<Double> accounts, int from, int to, int amount){
synchronized(accounts){ //选定账户对象的锁来锁定,一石二鸟
accounts.set(from, accounts.get(from) - amount);
accounts.set(to, accounts.get(to) + amount);
...
}
}

  这个方法可以工作,但它却要完全依赖与这样一个事实,即accounts对象存在!这样具有耦合性,代码太脆弱

  因此,通常不推荐使用客户端锁定,要用同步块就直接新建一个Object对象来锁定就ok了~

监视器的概念


0.前言:锁和条件是线程同步的强大工具,但不是面向对象的,需要手动设置,于是就有了面向对象的监视器概念(monitor),使程序员不需要考虑如何加锁就可以保证多线程的安全性;

1.监视器标准定义(1970s提出的概念):

  1)监视器是只包含私有域的类;

  2)每个监视器类的对象有一个相关的锁(对应Java:内部锁);

  3)使用该锁对所有的方法进行加锁(对应Java:所有方法是synchronized的),调用时自动获得对象锁,返回时自动释放该锁;

  4)该锁可以有任意多个相关条件

2.Java设计者以不精确的方式采用了监视器概念:

  1)Java中每一个对象都有一个内部锁和内部条件

  2)如果一个方法调用synchronized声明,那么该方法就如同一个监视器方法(自动加锁放锁)

  3)通过wait/notifyAll/notify来访问条件变量

3.然而Java对象以下三个方面使其背离了监视器的定义:

  1)域不要求是private的;

  2)方法不要求必须是synchronized的;

  3)内部锁对客户是可用的;