Java多线程并发修改问题的分析
我们先来看一个银行转款的问题:
//这是一个银行类
public class Bank {
private final double[] accounts;
public Bank(int n, double initialBalance) {
accounts = new double[n];
Arrays.fill(accounts, initialBalance);
}
public void transfer(int from, int to, double account) {
if (accounts[from] < account) return;
System.out.print(Thread.currentThread());
accounts[from] -= account;
System.out.printf("%10.2f from %d to %d", account, from, to);
=========================================================================================
//这是我们要进行分析的地方
accounts[to] += account; =========================================================================================
System.out.printf("Total balance: %10.2f%n", getTotalBalance());
}
public double getTotalBalance() {
double sum = 0;
for (double a :
accounts) {
sum += a;
}
return sum;
}
public int size() {
return accounts.length;
}
}
//这是一个测试类
public class UnsynchBankTest {
public static final int NACCOUNTS = 100;
public static final double INITIAL_BALANCE = 1000;
public static final double MAX_ACCOUNT = 1000;
public static final int DELAY = 10;
public static void main(String[] args) {
Bank bank = new Bank(NACCOUNTS,INITIAL_BALANCE);
for (int i = 0;i < NACCOUNTS;i++){
int fromAccount = i;
Runnable runnable = ()->{
try{
while (true){
int toAccount = (int) (bank.size()*Math.random());
double amount = MAX_ACCOUNT*Math.random();
bank.transfer(fromAccount,toAccount,amount);
Thread.sleep((int) (DELAY*Math.random()));
}
} catch (InterruptedException e) {
e.printStackTrace();
}
};
Thread t = new Thread(runnable);
t.start();
}
}
}
accounts[to] += account
不是一个原子操作(也就是这行代码由许多指令组成
)。可能有如下的指令:
- 将accounts[to]加载到寄存器。
- 增加amount。
- 将结果写回accounts[to]。
多线程问题由就像下图一样会出现:
- 由上图可以看到,由于Thread1的store还有执行就被Thread2线程抢占了,然后
Thread2的store
是无效的。这就是多线程共同改变变量所带来的问题。
多线程并发修改问题的解决方案———锁机制
Java有两种机制可以防止代码块并发访问的问题。
- 通过
synchronized关键词
来解决。 - 通过Java SE 5.0引入的
ReentrantLock类
来进行解决。
通过ReentrantLock保护代码块
ReentrantLock(锁对象)
//这是用ReentrantLock的基本结构
myLock.lock();
try{
critical section
}finally{
myLock.unlock();
}
这个结构确保了在任何时刻只有一个线程进入临界区
。一旦一个线程封锁了锁对象,其它任何线程都无法通过lock语句。当其它线程调用lock的时候,它们都会被阻塞
。
像上面一样把锁对象的释放放在finally代码块中非常的重要
。避免了在临界区的代码抛出异常后,锁没有释放而造成其它线程永远的阻塞。
还有一个需要注意的是,使用锁就不能使用带资源的try语句
。
- 首先是解锁的方法名不是close。
- 然后,就是
带资源的try语句希望在它的首部声明一个新变量
。但是如果使用一个锁,你是需要多个变量共享那个锁变量的(而不是一个新变量)。
还有需要注意的是:不同线程访问同一个ReentrantLock对象,会产生阻塞。但是,如果是访问的不同的ReentrantLock对象,它们之间没有关系。
在一个被锁保护的代码中可以调用另一个使用相同的锁的方法,这叫做锁是可重入
的。
条件对象
条件对象(条件变量)
是用来对一个获得锁却又不能做有用工作的线程进行处理的类
。
一个锁对象可以有一个或多个相关的条件对象
。你可以使用newCondition方法
获得一个条件对象。
我们先来看下上面我们要改成条件对象的部分:
//没有使用条件对象:当账号的余额小于要转的数量的时候就进行return返回。
if (accounts[from] < account) return;
//使用条件对象
while (accounts[from] < amount){
条件对象.await();
}
......(经过一系列操作)
//当账号余额发生变化的时候
条件对象.signalAll();
这里我们得理解的是当一个条件对象调用await方法
的时候,当然线程现在被阻塞了,并放弃了锁
。这样其它线程就能获取锁。
还得注意一个问题,等待获得锁的线程和调用await方法的线程
存在着本质上的区别的。当一个线程调用了await方法后,它进入了该条件的等待集
。当锁可用的时候,调用await方法的线程不能立即就解除了阻塞状态。只有当另一个线程调用同一个条件的signalAll方法的时候,该线程才能解除阻塞状态。
当调用await方法的线程重新获取了锁后,它会从被阻塞的地方继续执行
。
由条件对象导致死锁的两种情况:
- 如果
所有的线程都调用了await方法处于阻塞状态
,没有其它的线程调用signalAll方法来解除它们的阻塞,将会造成死锁现象。此时怎个程序挂着。 - 如果
一个线程通过signal方法来解除一个等待线程的阻塞状态,但是,解除后发现还是不满足执行条件,又被阻塞了
。这时如果全部线程都处于阻塞状态,就产生死锁。
这里说明一下两个激活线程的方法:
- ignalAll方法:
解除等待线程的阻塞状态
,让这些线程在当前线程退出同步方法后,进行竞争执行。 - signal方法:
随机解除一个等待线程的阻塞状态
。
通过Synchronized关键字解决并发问题
使用Synchronized方法解决并发问题
其实,使用Synchronized的关键字其本质是获取一个线程的内部的对象锁。简单的来说就是,每个对象都有一个内部锁,并且该锁有一个内部条件;然后我们通过调用同步方法来获取这个锁
。就像下面的等价代码一样。
public synchronized void method(){
method body;
}
//等价于下面
public void method(){
this.intrinsicLock.lock();
try{
method body;
}finally{
this.intrinsicLock.unlock();
}
}
使用同步代码块来解决并发问题
上面已经说过每一个对象都有一个内部的对象了。我们除了可以通过同步方法来获取这个锁以外,还可以通过通过代码块来获取这个锁。具体的模版如下:
synchronized(object){
critical section;
}
object
如果是对实例方法
进行同步就直接this
就行;如果是对静态方法
进行同步那么object就是类.class
。
synchronized常见方法的使用(对于上面两种方式)
在使用synchronized关键字的时候,Object类
的三个final
方法wait
、notifyAll
以及notify
分别对应Condition(条件对象)
的await
、signalAll
、signal
方法。
一个类的静态方法
也能够使用synchronized关键字来进行同步。当该方法被调用时,该类的class对象(类.class)
的锁被锁住。因此,没有其它线程可以调用这个类的这个或者任何其它的同步静态方法
。
PS:上面的几种方式来解决并发问题的代码我就不列出来了,作为一个思考自己去写吧==
volatile域
volatilte关键字有下面两个作用:
- 由
JMM模型(Java内存模型)
可以知道所有线程都共享一个主内存区(线程共享的)
,每一个线程都有自己的一个工作区(线程私有的,存储主内存区中的变量副本)
。然后,volatile的第一个作用就是让你读取的变量都是主内存区中最新的变量;当一个线程修改了volatile变量,这个变量会立即写入到主内存区。 - 第二个作用就是禁止进行指令重排序优化。
具体的理解我就不说了,要说的话还有很多的东西。详细理解请参考全面理解Java内存模型(JMM)及volatile关键字。
总结
今天我们主要是引出了多线程的并发问题。并给出了解决方案:这里我们细分为三种方式(其实都是通过锁对象来进行解决的):
- 通过ReentrantLock锁来进行解决
- 通过synchronized方法来进行解决
- 通过同步代码块来进行解决
然后我们在最后又介绍了一下volatile关键字。
参考
- Java核心技术 卷一
- 全面理解Java内存模型(JMM)及volatile关键字
转载请注明地址