【原创】Java并发同步知识——《Java核心技术》

版权声明:本文为博主ExcelMann的原创文章,未经博主允许不得转载。

Java并发同步知识

作者:ExcelMann,转载需注明。

一、竞态条件的一个例子

本节中,给出一个例子,会看到如果没有同步,会发生什么。

首先是Bank类的transfer方法的代码:

public void transfer(int from, int to, double amount)
{
	System.out.print(Thread.currentThread());
	accounts[from] -= amount;
	System.out.printf("...");
	accounts[to] += amount;
	System.out.printf("...");
}

下面是Runnable实例的代码。run方法不断地从一个给定银行账户取钱,在每次迭代中,run方法选择一个随机的目标账户和一个随机金额,调用bank对象的transfer方法,然后休眠。

Runnable r = ()->{
	try{
		while(true)
		{
			int toAccount = (int)(bank.size() * Math.random());
			double amount = MAX_AMOUNT*Math.random();
			bank.transfer(fromAccount, toAccount, amount); //其中fromAccount是当前账户
			Thread.sleep((int) (DELAY *Math.random()));
		}
	}catch(InterruptedException e)
	{
		...
	}
};

这个模拟程序运行时,我们不清楚在某一时刻某个账户的金额有多少,但是可以确定的是,总的银行账户的金额是不变的。
不过,在运行的过程中,出现了问题。
总的银行账户金额开始出现变动。

二、竞态条件详解

上一节中运行了一个程序,其中有多个线程会更新银行账户余额。在一段时间过后,不知不觉出现了错误,可能有些钱会丢失,也可能几个账户同时有钱进账。

当两个线程试图同时更新一个账户时,就会出现这个问题。假设两个线程同时执行指令accounts[to] += amount;
其实accounts[to]是临界资源,同时访问时必须加锁,后续内容会提到

问题
问题在于这指令不是一个原子操作。这个指令可能如下处理:
1)将accounts[to]加载到寄存器;
2)增加amount;
3)将结果写回accounts[to];

分析原因
因此,假定线程1执行步骤1和2,然后它的运行权被抢占。此时,线程2被唤醒,执行了三个步骤完成。然后,线程1被唤醒并完成3步骤,此时会覆盖线程2所做的更新。这样一来,金额就不正确了。

真正的问题
其实真正的问题是,transfer方法可能在执行到中间的时候被中断(因此需要像我前面说的一样加锁)。(如果能够保证每个线程都顺利执行完transfer方法,那么银行的总金额就不会出现变动)

三、锁对象

这一节主要讲ReentrantLock类的有关内容,有利于理解后续的synchronized关键字。

  1. 两种机制防止并发访问代码块
    1)Java 5引入的ReentrantLock类;
    2)Java内置的synchronized关键字:该关键字会自动提供一个锁以及相关的“条件”;
  2. ReentrantLock保护代码块的基本结构如下(关键点在于unlock()方法要放在finally子句中):
myLock.lock(); //a ReentrantLock Object
try{
	...
}
finally
{
	myLock.unlock(); //make sure the lock is unlocked even if an exception is thrown
}
  1. 注意:使用锁时,不能使用try-with-resources语句;两个原因:首先是因为解锁名不是close,第二是因为可能想使用多个线程共享的那个变量(而try-with-resources的首部希望声明一个新变量);
  2. 小技巧:注意每个Bank对象都有自己的ReentrantLock对象。
    因为两个线程试图访问同一个Bank对象时,那么这个锁可以用来保证串行化访问。
    不过,如果两个线程访问的是不同的Bank(如两个不同的银行),每个线程会获得不同的锁对象,互相不受牵制,这本该如此。
  3. 重入锁:这个锁称为重入(reentrant)锁,因为线程可以反复获得已拥有的这个锁。
    锁有一个持有计数来跟踪对lock方法的嵌套调用。
    作用:由于这个特性,被一个锁保护的代码可以调用另一个被该锁保护的方法。例如,transfer方法调用getTotalBalance方法(该方法也用bankLock锁保护),这也会封锁一次bankLock对象。
  4. 特殊注意:要注意确保临界区的代码不会因为抛出异常而跳出临界区。因为如果在临界区代码结束之前抛出异常而跳出语句,虽然finally会解锁,但是此时对象可能出于被破坏的状态;

四、条件对象

通常,线程进入临界区发现需要满足某个条件之后才能执行代码。

条件对象作用:可以使用一个条件对象管理那些已经获得了一个锁,但是却不能做有用工作的线程;

例子:对于银行的模拟程序,如果一个账户没有足够的资金转账,我们不希望从这样的账户转出资金。
注意,不能像下面这样执行代码:

if(bank.getBalance(from) >= amount)
	bank.transfer(from, to, amount);

在成功通过if条件后,执行transfer方法之前,当前线程完全有可能被中断。此时,如果线程再次运行前,账户余额可能已经被其他线程操作后低于提款金额,就会出现问题。
所以必须确保在检查金额与转账活动之间没有任何其他线程修改金额。为此,可以使用一个锁来保护这个测试和转账操作:

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

条件对象的使用:对于该代码,如果这个线程刚刚获得了bankLock的排他性访问权,因此别的线程没有存款的机会。这里就要引入条件对象。
一个锁对象可以有一个或多个相关联的条件对象。

相关方法及原理

  1. newCondition:使用该方法获得一个锁的条件对象。

  2. await:使用条件对象的该方法,会暂停线程,并放弃锁。调用该方法的线程会进入这个条件的等待集,当锁可用时,该线程不会变为可运行状态。实际上,它仍保持非活动状态,直到另一个线程在同一条件上调用singalAll方法。

  3. signalAll:使用条件对象的该方法,会重新激活等待这个条件的所有线程。该方法仅仅是通知等待集中的线程:现在可能满足条件,请你再次检查一下条件,所以才结合while循环。

  4. 激活后的流程:当这些线程从等待集中移出后,它们再次成为可运行的线程,调度器最终将再次将它们激活。同时,它们会尝试重新进入该对象。一旦锁可用,它们中的某个线程将从await调用返回,得到这个锁,并从之前暂停的地方继续执行。
    此时,线程应该再次测试条件。不能保证现在就一定能满足条件。

transfer方法的改进

class Bank
{
	private Condition sufficientFunds;
	...
	public Bank()
	{
		...
		sufficientFunds = bankLock.newCondition();
	}
	
	public void transfer(int from, int to, int amount)
	{
		bankLock.lock();
		try{
			while(accounts[from] < amout) //常用while循环结合条件对象
				sufficienFunds.await(); //发现资金不足,会调用该方法,进入等待集
			// transfer funds
			...
			sufficientFunds.signalAll(); //当线程完成时,会调用该方法,提示等待集的线程再次检查条件
		}
		finally{
			bankLock.unlock();
		}
	}
}

应该什么时候调用signalAll:从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll方法;

signal方法:该方法只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。如果随机选择的线程发现自己仍然不能运行,它就会再次阻塞。如果没有其他线程再次调用signal,则会出现死锁;

五、synchronized关键字

先对锁和条件的要点做一个总结:
1)锁用来保护代码片段,一次只能有一个线程执行被保护的代码;
2)锁可以管理试图进入被保护代码段的线程;
3)一个锁可以有一个或者多个相关联的条件对象;
4)每个条件对象管理那些已经进入被保护代码段但是还不能运行的线程;

  1. Java内置机制:Java每个对象都有一个内部锁(称为内部对象锁)。如果一个方法声明为synchronized关键字,那么对象的锁将保护这个方法(也就是说,要调用该方法,首先得获得该对象的内部锁);
  2. 例子:例如,可以简单地将Bank类的transfer方法声明为synchronized,而不必使用一个显式的锁;
  3. 条件对象:内部对象锁只有一个关联条件。其中调用wait方法和notifyAll方法,前者将线程加入到等待集中,后者可以解除线程的阻塞;
  4. 注意:必须理解,每个对象都有一个内部锁,不同对象的内部锁不同;
  5. 静态方法同步:将静态方法声明为同步也是合法的。如果调用这样一个方法,它会获得类对象的内部锁。
    例如,如果Bank类有一个静态同步方法,当调用这个方法时,Bank.class对象锁会锁定,导致没有其他线程可以访问该静态同步方法或者该类的其他静态同步方法;
  6. 内部锁和条件存在一些限制
    1)不能中断一个正在尝试获得锁的线程;
    2)不能指定尝试获得锁时的超时时间;
    3)每个锁仅有一个条件可能是不够的;

总结:使用哪种做法
1)最好不好使用Lock/Condition也不使用synchronized关键字。在许多情况下,可以使用java.util.concurrent包中的某种机制,它会为你处理所有的锁定;
2)如果synchronized关键字适合你的程序,那么尽量使用这种做法,这样可以煎炒编写的代码量,还能减少出错的概率;
3)如果特别需要Lock/Condition结构提供的额外能力(内部锁和条件存在一些限制),则使用这种方法;

六、同步块

还有另一种机制可以获得锁:即进入一个同步块

形式:当线程进入如下形式的块时,它会获得obj对象的锁

synchronized(obj) // this is the syntax for a synchronized block
{
	critical section
}

客户端锁定:使用一个对象的锁来实现额外的原子操作,这种做法称为客户端锁定。
例如,考虑Vector类,它的方法是同步的。现在假设我们将银行余额存储在一个Vector<Double>中。下面是transfer方法的实现:

public void transfer(Vector<Double> accounts, int from, int to, int amount)
{
	synchronized(accounts)
	{
		accounts.set(from, accounts.get(from) - amount);
		accounts.set(to, accoutns.get(to) + amount);
	}
}

对于该方法本身是可行的,但是是在一个假设上成立的,即假设Vector类会对自己的所有更改方法都使用内部锁。但是实际上不一定成立。因此其实客户端锁定很脆弱,最好不要使用这种机制。

七、监视器概念

监视器:研究人员希望不要求程序员考虑显式锁就可以保证多线程的安全性。最成功的解决方案之一就是监视器。

监视器的特性

  1. 监视器是只包含私有字段的类;
  2. 监视器类的每个对象有一个关联的锁;
  3. 所有方法由这个锁锁定。换句话说,如果客户端调用obj.method(),那么obj对象的锁在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的字段都是私有的,所以可以保证字段同时只能由一个线程访问;
  4. 锁可以有任意多个关联的条件;

八、volatile字段

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

例子说明
如果有以下代码,

private boolean done;
public synchronized boolean isDone(){ return done; }
public synchronized void setDone() { done = true; }

如果另一个线程已经对该对象加锁,isDone和setDone方法可能会阻塞。对于该问题,其实可以通过为这个变量加一个单独的锁,但是,这会很麻烦。
在这种情况下,可以将字段声明为volatile:

private volatile boolean done;
public boolean isDone(){ return done; }
public void setDone() { done = true; }

编译器会插入适当的代码,以确保如果一个线程对done变量做了修改,这个修改对读取这个变量的所有其他线程都可见。

注意:volatile变量不能提供原子性!

九、final变量

还有一种情况可以安全地访问一个共享字段,即这个字段声明为final
考虑以下声明:

final var accounts = new HashMap<String, Double>();

其他线程会在构造器完成构造之后才看到这个accounts变量。
如果不使用final,就不能保证其他线程看到的是accounts更新后的值,它们可能都只是看到null,而不是新构造的HashMap。

记住:当然,对这个映射的操作并不是线程安全的。如果有多个线程要对该映射进行更改和肚脐,仍然需要同步。

十、原子性(这里稍微了解。。)

  1. java.util.concurrent.atomic包:该包中很多类使用了很高效的机器级指令(而没有使用锁)来保证其他操作的原子性;
  2. compareAndSet方法:有很多方法可以以原子方式设置和增减值,不过,如果希望完成更复杂的更新,就必须使用compareAndSet方法;
  3. 如果有大量线程要访问相同的原子值,性能会大幅度下降,因为乐观更新需要太多次重试。LongAdder和LongAccumulator类解决了这个问题;

十一、死锁

死锁的介绍:有可能会因为每一个线程要等待更多的钱款存入而导致所有线程都被阻塞,这种状态称为死锁;

死锁的调试:当程序因死锁挂起时,按下Ctrl + \,将得到一个线程转储,这会列出所有线程。每一个线程会有一个栈轨迹,告诉你线程当前在哪里阻塞(可以运行jconsole并参考线程面板);

导致死锁的多种可能情况

  1. 例如让第i个线程负责向第i个账户存钱,而不是从第i个账户取钱。这样一来,有可能所有线程都集中到一个账户中(SynchBankTest程序中,交换fromAccount和toAccount),每一个线程都试图从这个账户取出大于该账户余额的钱;
  2. 在SynchBankTest程序中,将signalAll方法修改为signal方法,会发现该程序最终会挂起。(原因之前提过,是因为signal只是随机挑选一个等待集解除阻塞)

Java没有任何东西可以避免或者打破这种死锁。必须仔细设计程序,确保不会出现死锁。

十二、线程局部变量(ThreadLocal)

  1. 有时可能要避免共享变量,使用ThreadLocal辅助类为每个线程提供各自的实例
  2. 例子:对于SimpleDateFormat类不是线程安全的,假设以下代码,在并发时结果可能很混乱,因为dataFormat使用的内部数据结构可能会被并发的访问所破坏。(可以使用同步,但是开销很大;可以构造一个局部对象,不过很浪费;)
//假设有一个静态变量
public static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
//如果两个线程都执行以下操作
String dateStamp = dataFormat.format(new Date());
  1. 对于以上例子,如果要为每个线程构造一个实例,可以使用以下代码:(在一个给定线程中首次调用get时,会调用构造器中的lambda表达式。在此之后,get方法会返回属于当前现线程的那个实例)
public static final Thread<SimpleDateFormat> dateFormat
 = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

//要使用具体的格式化方法,可以调用
String dateStamp = dateFormat.get().format(new Date());

十三、为什么废弃stop和suspend方法

  1. stop废弃的原因:当一个线程要终止另一个线程时,它无法知道什么时候调用stop方法是安全的(非破坏状态),而什么时候会导致对象被破坏;
  2. suspend废弃的原因:如果用suspend挂起一个持有锁的线程,那么,在线程恢复运行之前这个锁是不可用的(不同于条件等待)。如果此时调用suspend方法的线程试图获得同一个锁,则会造成死锁;
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值