多线程编程时有趣的事情,它很容易突然出现 “错误情况”,这是由系统的 线程调度器 具有一定的随机性造成的,不过即使程序偶尔出现问题,那也是由于编程不当引起的,当使用多个线程来访问同一个数据时,很容易 “偶然”出现线程安全问题
-
下列示范一个可能出现的问题:
-
账户类
public class Account {
public String accountNo;// 账户别号
public double balance;// 金额
public Account(String accountNo, double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
}
- 线程类
public class DrawThread extends Thread {
// 模拟用户账户
public Account account;
// 取的钱
public double draw;
// 线程名、账户、取的钱数
public DrawThread(String name, Account account, double draw) {
super(name);// 调用父类构造设置线程名
this.account = account;
this.draw = draw;
}
public void run() {
if (account.balance >= draw) {
System.out.println(getName() + "取钱成功!吐出钞票:" + draw);
//此处问题可能———————————————— 1
// 修改余额
account.balance -= draw;
System.out.println("\t余额为:" + account.balance);
} else {
System.out.println(getName() + "取钱失败!余额不足" );
}
}
}
- 测试类
public static void main(String[] args) throws InterruptedException {
//创建一个账户
Account acct = new Account("123456",1000);
//模拟两个线程对同一个账户取钱
new DrawThread("乙",acct, 800).start();
//Thread.sleep(10);
new DrawThread("甲",acct, 800).start();
}
上列代码都及其不规范:只是为了演示一下效果
- 上列代码执行结果 : 会出现2种情况
①乙取钱成功!吐出钞票:800.0 ② 乙取钱成功!吐出钞票:800.0
余额为:200.0 甲取钱成功!吐出钞票:800.0
甲取钱失败!余额不足 余额为:200.0
余额为:-600.0
我们原以为是第一种情况 : 实则会出现第2种情况;出现第二种情况的原因是:
- 线程调度器具有一定随机性 > 第一个子线程运行到 线程类 (1) 位置时,此时便刚好未执行,这时线程调度器分配资源交由第二个子线程执行,此时也满足
account.balance >= draw
条件,所以这才导致了 这种 余额为负数的原因。
同步代码块
-
上列之所以会出现这种情况,是因为 run() 方法的方法体不具有同步安全性——程序有两个并线程在修改Account对象;而恰巧执行线程类 (1)处 代码执行线程切换,切换给另外一个修改 Account 对象的线程,所以就出现了问题。
-
为了解决如上问题, Java 多线程支持引入了 同步监视器来解决这个问题,使用同步监视器的通用方法 就是 同步代码块。同步代码块的语法格式入下:
synchronized(obj){
......
//此处的代码就是同步代码块
}
上面语法格式中 synchronized 后括号里的 obj 就是同步监视器,
上面代码的含义是: 线程开始执行同步代码块之前,必须获得同步监视器的锁定。
注意: 任何时刻只能有一个线程可以获得对同步监视器的锁定,当同步代码块执行完成后,该线程会释放对该同步监视器的锁定。
注意: 同步代码块中的锁对象可以是任意的对象;但多个线程时,要使用同一个锁对象才能够保证线程安全。
Java 允许任何对象作为同步监视器,但想一下同步监视器的目的:阻止两个线程对同一个共享资源进行并发访问,因此通常推荐使用可能被并发访问的共享资源充当同步监视器。
其余代码都一样修改下run() 方法便可
public void run() {
//使用 account 作为同步监视器,任何线程进入下面同步代码块之前
//必须获得对 account 账户锁定——其他线程无法获得锁,也就无法修改它
//这种做法符合 : “加锁-> 修改 -> 释放锁”的逻辑
synchronized (account) {
if (account.balance >= draw) {
System.out.println(getName() + "取钱成功!吐出钞票:" + draw);
// 修改余额
account.balance -= draw;
System.out.println("\t余额为:" + account.balance);
} else {
System.out.println(getName() + "取钱失败!余额不足");
}
}
}
- 上面程序使用 synchronized 将run() 方法里的方法体修改成同步代码块,
- 该同步代码块的同步监视器是 account 对象,这样就符合 “加锁->修改-> 释放锁” 的逻辑,
- 任何线程在修改指定资源之前,首先对该资源加锁,在加锁期间其他线程无法修改资源,当该线程修改完成后,该线程释放对该资源的锁定。
- 通过这种方法就可以保证并发线程在任一时刻只有一个线程可以进入修改共享资源的代码区 (也被称为临界区),所以同一时刻最多只有一个线程在线程处于临界区内,从而保证了线程的安全性。
如上使用同步代码块后:运行的结果始终是我们预期的,正确的。
同步方法
-
与同步代码块对应,Java 的多线程安全支持还提供了同步方法,同步方法就是 使用 synchronized 关键字来修饰的某个方法,则该方法称为同步方法。对于 synchronized 修饰的实例方法 (非 static 方法) 而言,无须显示指定同步监视器,同步方法的监视器是this,也就是调用该方法的对象。
-
通过使用同步方法可以非常方便地实现线程安全的类,线程安全的类具有如下特征。
- 该类的对象可以被多个线程安全的范围
- 每个线程调用对象的任意方法之后都将得到正确结果
- 每个线程调用该对象的任意方法之后,该对象状态依然保持合理状态。
public class Account {
public String accountNo;// 账户别号
public double balance;// 金额
public Account(String accountNo, double balance) {
this.accountNo = accountNo;
this.balance = balance;
}
//在账户类中提供修改 金额的同步方法
public synchronized void draw(double draw){
if (balance >= draw) {
System.out.println(Thread.currentThread().getName()
+ "取钱成功!吐出钞票:" + draw);
// 修改余额
balance -= draw;
System.out.println("\t余额为:" + balance);
} else {
System.out.println(Thread.currentThread().getName()
+ "取钱失败!余额不足");
}
}
上列代码把draw()方法变成同步方法,该同步方法的监视器是 this,因此对于同一个 Account 账户而言,任意时刻只能有一个线程获得对 Account对象的锁定,然后进入draw() 方法执行取钱操作——这样也就可以保证多个线程并发取钱的线程安全。
- 因为 Account 类中已经提供了 draw() 方法,DrawThread 线程类需要改写,该线程类的 run() 方法只要调用 Account 对象的 draw() 方法即可执行取钱操作。run() 如下:
public void run() {
//直接调用 account 对象的 draw() 方法来执行取钱操作
//同步方法的同步监视器是 this,this 代表调用 draw() 方法的对象
//也就是说,线程进入 draw() 方法之前,必须先对 account 对象加锁
account.draw(draw);
}
可变类的线程安全是以降低程序的运行效率作为代码的,为了减少线程安全所带来的负面影响,程序可以采用如下策略
- 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源 (共享资源)的方法同步
- 如果可变类有两种运行环境,多线程环境和多线程环境。则应该为该可变类提供两种版本。即线程不安全版本和线程安全版本。在单线程环境中使用线程不安全版本以保证性能,在多线程环境中使用线程安全版本。
释放同步监视器的锁定
任何线程进入同步代码块、同步方法之前、必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?程序无法显示释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定.
-
当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器
-
当前线程在同步代码块、同步方法中出现了break、return终止了该代码块、该方法的继续执行,当前线程会释放同步监视器。
-
当前线程在同步代码块、同步方法中出现了未处理的 Error 或 Exception,导致了该代码块、该方法异常结束时,当前线程会释放同步监视器。
-
当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的 wait() 方法,则当前线程暂停,并释放同步监视器。
-
线程执行同步代码块或同步方法时,程序调用 Thread.sleep()、Thread.yield() 方法来暂停当前线程的执行,当前线程不会释放同步监视器。
-
线程执行同步代码块时,其他线程调用了该线程的 suppend() 方法将该线程挂起,该线程不会释放同步监视器。当然,程序应该尽量避免使用 suspend() 和 resume() 方法来控制线程。
同步锁 (Lock)
-
从 Java 5 开始,Java 提供了一种功能强大的线程同步机制——通过显示定义同步锁对象来实现同步,在这种机制下,同步锁由 Lock 对象充当。
-
Lock 提供了比 synchronized 方法和 synchronized 代码块更广泛的锁定操作,Lock 运行实现更灵活的结构,可以具有差别很大的属性,并且支持多个相关的 Condition 对象。
-
Lock 是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象
某些锁可能允许对共享资源并发访问,如果 ReadWriteLock(读写锁),Lock、ReadWriterLock 是 Java 5提供的两个根接口,并为 Lock 提供了 ReentrantLock(可重入锁) 实现类,为 ReadWriteLock 提供了 ReentrantReadWriteLock 实现类.
Java 8新增加了新型的 StampedeLock 类,在大多数场景中它可以替代传统的 ReentrantReadWriteLock。
ReentrantReadWriteLock 为读写操作提供了 三种锁模式 : Writing、Reading、ReadingOptimistic。
实际线程安全的控制中,比较常用的是 ReentrantLock(可重入锁).使用该 Lock 对象可以显示地加锁、释放锁,通常使用ReentrantLock 的代码格式如下:
class X{
//定义锁对象
private final ReentrantLock lock = new ReentrantLock();
//定义需要保证线程安全的方法
public void m(){
//加锁
lock.lock();
try{
//需要保证线程安全的代码
// ... method body 方法体
}
//使用 finally 块来保证释放锁
finally{
lock.unlock();
}
}
}
使用 reentrantLock 对象来进行同步,加锁和释放锁出现在不同的作用范围内时,通常建议使用 finally 块来确保在必要时释放锁。
内容来自 《疯狂Java讲义》 为了方便查阅