线程安全分析
多个线程并发执行会带来安全问题,就好比搬家这项工作,虽然人多了干活会比较快,但是人多很容易损坏物件。在线程安全问题中,非常有名的问题就是银行取钱问题。
银行的取钱流程是这样的:
1.验证用户名和密码2. 用户输入取款金额3.系统判断余额是否大于取款金额,如果大于则取款成功;否则,取款失败。4. 系统更新账户余额
这个流程看起来没有任何问题,但是如果放到多线程并发情况下,就有可能出现问题。比如:
假设你的账户有1000元,当你在柜台成功取出1000元时(注意此时系统还没有将账户余额改为0,账户余额还是为1000 )。同时你的女朋友在取款机也正在取款,取款机去查询账户余额,发现是1000元,于是又吐给你女朋友1000元。
然后柜台将你的账户余额更新为0,取款机随后也将你的账户余额更新为0。
就这样,我们成功的在1000元的账户里,取出了2000元。这显然是不合理的。
下面让我们写一个多线程程序来模拟多个人取钱操作。
首先封装一个账户类,这个账户有账号和余额两个属性:
- /**
- * 账户类
- * @author liubing
- *
- */
- public class Account {
- //账号
- private String accountNo;
- //余额
- private int balance;
- /**
- * 构造函数,设置账号和余额
- * @param accountNo
- * @param balance
- */
- public Account(String accountNo, int balance) {
- this. accountNo = accountNo;
- this. balance = balance;
- }
- public String getAccountNo() {
- return accountNo;
- }
- public void setAccountNo(String accountNo) {
- this. accountNo = accountNo;
- }
- public int getBalance() {
- return balance;
- }
- public void setBalance( int balance) {
- this. balance = balance;
- }
- }
接下来写一个取钱的线程类,模拟用户取钱。
- public class DrawCashThread extends Thread{
- //模拟用户账户
- private Account account;
- //模拟取钱数
- private int drawCash;
- public DrawCashThread(String name, Account account, int drawCash) {
- super(name);
- this. account = account;
- this. drawCash = drawCash;
- }
- /**
- * 模拟多个线程同时取钱操作
- */
- @Override
- public void run() {
- //判断账户余额大于取款金额
- if( account.getBalance() >= drawCash) {
- System. out.println(getName() + " 取钱成功,取款金额:" + drawCash);
- /*
- try {
- //强制线程切换
- sleep(1);
- } catch (Exception e) {
- e.printStackTrace();
- }
- */
- //修改余额
- account.setBalance( account.getBalance() - drawCash);
- System. out.println( "此时余额:" + account.getBalance());
- } else {
- System. out.println(getName() + " 取钱失败,余额不足!" );
- }
- }
- }
好了,这就是我们根据银行取钱步骤,模拟的账户和取钱操作,接下来,我们写一个主程序测试一下。创建一个账户,然后开启两个线程去取钱。
main函数如下:
- public static void main(String[] args) {
- //新建我的账户,余额1000元
- Account account = new Account( "我的账户" , 1000);
- //张三来取款1000元
- new DrawCashThread( "张三", account, 1000).start();
- //李四来取款1000元
- new DrawCashThread( "李四" , account, 1000).start();
- }
多次运行,发现每次的运行结果都不太一样。因为线程的调度是不确定的,大多数的运算结果反而是错的,偶尔出现预期的正确的结果。
注意:单核和多核cpu运算的结果是不一样的,单核的机器运算出正确的结果较多,需要将
DrawCashThread代码中的注释打开,强制线程切换,才能看到错误的结果。在多核机器上很容易出现错误结果。
正确的结果:
张三 取钱成功,取款金额:1000
此时余额:0
李四 取钱失败,余额不足!
|
错误的结果:
- synchronized (obj) {
- }
其中obj就是同步监视器,上面代码的含义是:线程开始执行同步代码块之前,必须先获得对同步监视器的锁定。任何时候只能有一条线程可以获得对同步监视器的锁定。同步代码执行结束后,该线程自然释放了对同步监视器的锁定。
这个很好理解,synchronized好比是一个厕所,obj就是一把锁。每个上厕所的人办事之前,先把门儿锁上。办完事把门儿打开,以便别人上厕所。
同步监视器的作用:防止多个线程对共享资源进行并发访问。因为通常选取共享资源作为同步监视器。
所以上面的取钱程序,我们可以考虑使用account作为同步监视器,改造后代码如下:
- public class DrawCashThread extends Thread{
- //模拟用户账户
- private Account account;
- //模拟取钱数
- private int drawCash;
- public DrawCashThread(String name, Account account, int drawCash) {
- super(name);
- this. account = account;
- this. drawCash = drawCash;
- }
- /**
- * 模拟多个线程同时取钱操作
- */
- @Override
- public void run() {
- //使用account作为同步监视器,任何线程进入下面同步代码块之前
- //先获得对account对象的锁定,其他线程无法获得锁,也就无法修改它
- //符合:加锁--修改--释放锁逻辑
- synchronized ( account) {
- //判断账户余额大于取款金额
- if( account.getBalance() >= drawCash) {
- System. out.println(getName() + " 取钱成功,取款金额:" + drawCash );
- /*
- try {
- //强制线程切换
- sleep(1);
- } catch (Exception e) {
- e.printStackTrace();
- }
- */
- //修改余额
- account.setBalance( account.getBalance() - drawCash);
- System. out.println( "此时余额:" + account.getBalance());
- } else {
- System. out.println(getName() + " 取钱失败,余额不足!" );
- }
- }
- }
- }
这次我们将run方法内加入了synchronized同步代码块,使用account对象作为同步资源监视器。任何线程在修改account对象之前,首先要对account进行加锁,在account对象锁定期间,其他线程是无法修改该资源的。修改完成后,该线程释放对account资源的锁定。
这样就可以保证并发线程在任意时刻只有一条线程可以进入修改共享资源的代码区,从而保证了线程的安全性。
同步方法:
与同步代码块对应的,java多线程中还提供了同步方法。同步方法就是synchronized关键字来修饰某个方法。对于同步方法而言,无需显示指定同步监视器,同步方法的同步监视器就是this,也就是该对象本身。
在上面取钱的程序中,不安全的因素就在于多个线程对balance的修改。所以我们就可以讲取钱操作封装成一个同步方法,保证线程对balance的修改是线程安全的即可。
Account改造如下:
- /**
- * 账户类
- * @author liubing
- *
- */
- public class Account {
- //账号
- private String accountNo;
- //余额
- private int balance;
- /**
- * 构造函数,设置账号和余额
- * @param accountNo
- * @param balance
- */
- public Account(String accountNo, int balance) {
- this. accountNo = accountNo;
- this. balance = balance;
- }
- public String getAccountNo() {
- return accountNo;
- }
- public void setAccountNo(String accountNo) {
- this. accountNo = accountNo;
- }
- public int getBalance() {
- return balance;
- }
- //因为账户余额不能随便修改,所以取消balance的setter方法
- // public void setBalance( int balance) {
- // this.balance = balance;
- // }
- //提供一个线程安全的方法完成取钱操作
- public synchronized void drawCash( int drawAccount) {
- if( balance >= drawAccount) {
- System. out.println(Thread. currentThread().getName() + " 取钱成功,取款金额:" + drawAccount);
- //修改余额
- balance -= drawAccount;
- System. out.println( "此时余额:" + balance );
- } else {
- System. out.println(Thread. currentThread().getName() + " 取钱失败,余额不足!" );
- }
- }
- }
我们将取钱的方法重构到了Account类,并使用synchronized修饰该方法,使其成为同步方法。同步方法的同步监视器是this,因为对于同一account对象来说,任意时刻只能有一个线程获得对account对象的锁定,然后执行取钱的操作。这样就可以保证线程安全。
对于取钱的线程来说,只要在run方法中调用account对象的
drawCash()方法即可,很简单,这里就不给出代码了。
锁机制是以降低程序运行效率为代价的,有一定的负面影响。就像搬家一样,虽然将冰箱锁住了,保证了冰箱的完整安全。但是搬冰箱的人需要相互协调,喊着口号才能搬走冰箱。所以我们在使用锁机制时,将锁控制的范围尽量的集中和缩小,仅对共享资源的操作部分进行锁定和同步,以减少对效率的损耗。