从零学习JAVA多线程(三):线程的同步问题

线程同步问题的产生

代码演示

想要知道线程同步问题产生的原因,一段代码可能会更加直接。
此处使用《Java核心技术》中的例子解释问题,是一个银行账户转账的业务场景演示,首先创建一个Bank类,用于进行转账操作

package unsynch;

/**
 * A bank with a number of bank accounts.
 * @version 1.30 2004-08-01
 * @author Cay Horstmann
 */
public class Bank
{
   private final double[] accounts;
   public Bank(int n, double initialBalance)
   {
      accounts = new double[n];
      for (int i = 0; i < accounts.length; i++)
         accounts[i] = initialBalance;
   }

   public void transfer(int from, int to, double amount)
   {
      if (accounts[from] < amount) return;
      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());
   }

   public double getTotalBalance()
   {
      double sum = 0;

      for (double a : accounts)
         sum += a;

      return sum;
   }

   public int size()
   {
      return accounts.length;
   }
}

开启线程进行测试

package unsynch;

public class UnsynchBankTest
{
   public static final int NACCOUNTS = 100;
   public static final double INITIAL_BALANCE = 1000;

   public static void main(String[] args)
   {
      Bank b = new Bank(NACCOUNTS, INITIAL_BALANCE);
      int i;
      for (i = 0; i < NACCOUNTS; i++)
      {
         TransferRunnable r = new TransferRunnable(b, i, INITIAL_BALANCE);
         Thread t = new Thread(r);
         t.start();
      }
   }
}

部分输出结果截取:

Thread[Thread-81,5,main]     179.78 from 81 to 6 Total Balance:   99168.53
Thread[Thread-36,5,main]     605.41 from 36 to 80 Total Balance:   99168.53
Thread[Thread-55,5,main]     846.49 from 55 to 41 Total Balance:   99168.53
Thread[Thread-53,5,main]     162.86 from 53 to 98 Total Balance:   99168.53
Thread[Thread-11,5,main]      60.91 from 11 to 24 Total Balance:   99168.53
Thread[Thread-68,5,main]     626.75 from 68 to 58 Total Balance:   99168.53
Thread[Thread-57,5,main]     304.73 from 57 to 39 Total Balance:   99168.53
Thread[Thread-98,5,main]     472.02 from 98 to 94 Total Balance:   99168.53
Thread[Thread-96,5,main]     226.75 from 96 to 80 Total Balance:   99168.53
Thread[Thread-20,5,main]     889.86 from 20 to 36 Total Balance:   99168.53

从结果里,我们就能看到发生了可怕的事情,在不断的转账操作中,银行卡内的总额发生了变化,不再是初始100*1000=100000。

原因分析

之所以出现这个原因,是因为在线程的运行过中,出现了 两个线程同时更新一个账户的情况。在这个情况下就会出现其中一个线程的操作在没有完成的情况下直接发生结果丢失,最终是我们的程序发生错误,计算结果不再可靠。

解决线程同步问题的两种方案

我们知道产生同步问题的原因是由于不同的线程操作了同一个对象,导致对象在多个线程里同时发生了值变化,从而产生的问题。那么,理论上讲,我们只要能够从代码层面避免多个线程同时操作一个对象就可以了。

使用lock解决同步问题(理解原理)

锁对象

在Java SE 5中引入了ReentrantLock类,我们可以使用ReentrantLock为不能同时执行的代码加锁,保证同一时间只有一个线程在操作我们对象,而其他对象必须等待前一个线程完成操作才能进入代码执行,从而解决同步问题。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Bank {
    private final double[] accounts;

    public Bank(int n, double initialBalance) {
        accounts = new double[n];
        for (int i = 0; i < accounts.length; i++)
            accounts[i] = initialBalance;
    }

    private Lock bankLock = new ReentrantLock();// ReentrantLock implements the Lock interface

    public void transfer(int from, int to, int amount) {
        bankLock.lock();
        try {
            System.out.print(Thread.currentThread());
            accounts[from] -= amount;
            System.out.printf(" X10.2f from %A to Xd", amount, from, to);
            accounts[to] += amount;
            System.out.printf(" Total Balance: X10.2fXn", getTotalBalance());
        } finally {
            bankLock.unlock();
        }
    }
    public double getTotalBalance() {
        double sum = 0;

        for (double a : accounts)
            sum += a;

        return sum;
    }

     public int size() {
        return accounts.length;
    }
}

由于我们创建了一个锁,并且使用它将transfer方法加锁了,那么一个bank实例,同时只能被一个线程执行这个方法。如果当前线程没有执行完try块中的代码,那后来的线程只能停在这里,直到当前线程执行完毕并且将finally中的解锁动作完成,后来的线程才有可能进入代码块运行。
我们在使用锁时,必须按照以下的流程,如果不将解锁动作放入finally中,万一解锁动作没有被执行,那么后来的线程将永远不能进入try块。

 bankLock.lock();
        try {
           ......
        } finally {
            bankLock.unlock();
        }

所谓重入是指,线程可以重复地获得已经持有的锁。锁保持一个持有计数( hold
count) 来跟踪对 lock() 方法的嵌套调用。线程在每一次调用 lock() 都要调用 unlock() 来释放锁。
我们看到在上面的try块中,transfer()方法调用了getTotalBalance(),这时候bankLock就会自动加锁getTotalBalance()方法,其hold count就会+1,等到方法执行完毕,hold count-1。当try块中的方法执行完毕时,hold count再次-1,值归零,线程才能释放锁。

条件对象

考虑实际业务中的特殊情况,还有可能会出现线程拿到了代码执行权,但是发现当前条件不能执行代码,切合到我们当前的例子上,可能就是,一个线程要执行转出的动作,但是账户内已经没有足额的钱。
如果不对这种情况进行处理程序异常或者直接卡死的错误。
正常的处理方式,我们就希望当一个线程获得当前代码段的执行权时,能够先去检查是不是符合执行当前代码段的条件,如果符合就执行,不符合放弃执行权等待、直至条件符合时再请求执行权并执行。
Java为我们提供了一个便捷的方式来解决这个问题,那就是给lock加上条件,称之为 条件对象 或者 条件变量。一个lock可以有多个条件变量。
下面给出设置条件对象的代码段:

   private Condition sufficientFunds;

   public void transfer(int from, int to, double amount) throws InterruptedException
   {
      bankLock.lock();
      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();
      }
   }

注意代码

while (accounts[from] < amount)
            sufficientFunds.await();

当线程进入try块时,将首先判断是否符合执行标准,如果不符合则调用await()方法,当前线程解锁代码块,放弃执行权,进入等待。直到有别的线程给账户增加了余额使得当前线程可以正确执行,则会在别的线程解锁代码块之后拿回执行权,正常执行代码。
从使用的角度来讲,我们不需要关注await()方法调用了什么,但是了解背后的逻辑有助于我们正确的选择是否要使用这种方式,毕竟这种方法的开销并不低。

当一个线程调用await()时,当前线程就进入了该条件的 等待集。当锁可用的时候,线程并不会去尝试获取锁,这个线程被激活的唯一条件是,有别的线程在执行代码的时候发现条件被满足,调用了signalAll()方法通知等待集中的线程排队尝试执行。
signalAll()方法的功能是:通知正在等待的线程:此时有可能已经满足条件, 值得再次去检测该条件。
所以回头看上面的代码,我们try块的最后直接调用signalAll(),每次都去尝试唤醒,成本是比较高的,但这样有事没有选择的选择。
因为一个线程一旦调用await(),我们可以认为它就进入了假死状态,不会再去主动执行任何动作,只能寄希望于别的线程唤醒它。如果别的线程没有唤醒它,那么这些线程就会僵死在等待集里没办法执行动作,产生 死锁。死锁会导致进程内所有的线程阻塞,程序报销,为了避免这种情况,我们只能尽可能的尝试唤醒等待集中的线程。当然实际业务逻辑中我们需要更加谨慎的处理这个情况,这里是演示一下代码的执行逻辑。还有一个signal()方法可以随机唤醒等待集中的一个线程进行尝试,但是这个可靠性很低,建议还是不要使用。

总结一下锁的相关要点:

  • 锁用来保护代码片段, 任何时刻只能有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程。
  • 锁可以拥有一个或多个相关的条件对象。
  • 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

synchronized([’sɪŋkrə.naɪz])关键字(常用方式)

锁和条件对象为程序员提供了非常自由的方式来解决线程同步的问题,其实也有简单的办法,就是synchronized关键字。
我看过现在项目的代码,大部分都是在用synchronized来处理同步问题,这是一个简单通用的办法(但是锁和条件对象还是要知道的!)。
当我们看过上面一种办法之后,synchronized真的不要太简单。
1.Java每一个对象都一个内部锁,在一个方法声明处加上关键字synchronized,则方法的内部锁自动开启,任何调用方法的线程必须先获得方法的锁,当方法执行完毕时,对象锁自动unlock()。

public synchronized void method()
{
method body
}

这段代码就利用synchronized关键字完成了我们上面创建锁对象、加锁、解锁的过程。
2. 对象锁也支持条件对象,但是只支持一个,使用方法如下:

 public synchronized void transfer(int from, int to, double amount) throws InterruptedException
   {
      while (accounts[from] < amount)
         wait();
      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());
      notifyAll();
   }

其中wait()方法等同于await();notifyAll()等同于signalAll()
需要注意的是,如果在静态方法中使用synchronized关键字,方法被调用时整个对象的内部锁将被锁定。
synchronized也可以同步一个代码块:

Object obj = new Object();  
synchronized(obj){  
    code
}  

当我们使用这种方式的时候,获得了对象obj的对象锁,但其实我们创建obj是为了使用每个Java对象持有的锁。这种方法称之为 客户端锁定,但他并不安全可靠,不推荐使用。可以考虑这种场景:

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

这个方法可以工作,但是它完全依赖于这样一个事实, Vector 类对自己的所有可修改方法都使用内部锁。然而,这是真的吗? Vector 类的文档没有给出这样的承诺。不得不仔细研
究源代码并希望将来的版本能介绍非同步的可修改方法。

synchronized要理解起来完全没有成本,能够满足大部分使用场景,简单好用。但也有劣势:

  • 不能中断一个正在试图获得锁的线程。
  • 试图获得锁时不能设定超时。
  • 每个锁仅有单一的条件。

几个概念

监视器概念

监视器 是尝试从面向对象概念保证多线程安全性的产物:

  • 监视器是只包含私有域的类。
  • 每个监视器类的对象有一个相关的锁。
  • 使用该锁对所有的方法进行加锁。换句话说,如果客户端调用 obj.meth0d(), 那 么 obj对象的锁是在方法调用开始时自动获得, 并且当方法返回时自动释放该锁。因为所有
    的域是私有的,这样的安排可以确保一个线程在对对象操作时, 没有其他线程能访问
    该域。
  • 该锁可以有任意多个相关条件。

这导致了安全性的下降:

  • 域不要求必须是 private。
  • 方法不要求必须是 synchronized。
  • 内部锁对客户是可用的。

反正用的不是很多=,=很少在工程代码里看到

Volatile域

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

final变量

final关键字的作用是保证一个变量不会在类的构造方法完成之前被读取。

原子性

假设对共享变量除了赋值之外并不完成其他操作,那么可以将这些共享变量声明为
volatile。

死锁

所有线程都被阻塞,无法继续的状态成为死锁。

线程局部变量

使用 ThreadLocal 关键字使得声明的变量为当前线程独有。

public static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

在一个给定线程中首次调用get()时,会调用initialValue()方法。在此之后, get ()方法会返回属于当前线程的那个实例。

锁测试与超时

线程在调用 lock()方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。tryLock() 方法试图申请一个锁, 在成功获得锁后返回 true, 否则, 立即返回false, 而且线程可以立即离开去做其他事情。
可以给tryLock()增加超时参数:

tryLock(long time, TimeUnit unit)

TimeUnit 是一个枚举类型,可以取的值包括 SECONDS、MILLISECONDS, MICROSECONDS
和 NANOSECONDS。
lock()方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之
前一直处于阻塞状态。如果出现死锁,lock()方法就无法终止。
然而,如果调用带有用超时参数的 tryLock(), 那么如果线程在等待期间被中断,将抛出
InterruptedException异常。这是一个非常有用的特性,因为允许程序打破死锁。
也可以调用 locklnterruptibly() 方法。它就相当于一个超时设为无限的 tryLock()方法。
在等待一个条件时,也可以提供一个超时:

myCondition.await(100, TineUniBILLISECONDS))

如果一个线程被另一个线程通过调用 signalAll() 或 signal() 激活, 或者超时时限已达到,或者线程被中断,那么 await() 方法将返回。
如果等待的线程被中断, await 方法将抛出一个 InterruptedException 异常。在你希望出现这种情况时线程继续等待(可能不太合理,) 可以使用 awaitUninterruptibly()方法代替 await()。

读写锁

  • Lock readLock():得到一个可以被多个读操作共用的读锁, 但会排斥所有写操作。
  • Lock writeLock():得到一个写锁, 排斥所有其他的读操作和写操作

如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话, 后者是十分有用的, 允许对读者线程共享访问是合适的。而写者线程依然必须是互斥访问的。
演示代码:

  1. 构造一个ReentrantReadWriteLock对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock():
  1. 抽取读锁和写锁:
private Lock readLock = rwl . readLock();
private Lock writeLock = rwl .writeLock();
  1. 对所有的获取方法加读锁:
public double getTotalBalanceO
{
readLock.lockO;
try { . . . }
finally { readLock.unlock(); }
}
4 ) 对所有的修改方法加写锁:
public void transfer(. . .)
{
writeLock.lockO;
try { . . . }
finally { writeLock.unlock(); }
}

后面零零散散的概念实在是写不动了,有兴趣的可以自己多研究一下。能够掌握锁的原理和synchronized关键字的使用就算是能够初步使用多线程做些事情了。下一个讲阻塞队列。

附上版权声明:
原作者:Vi_error,博客地址:Vi_error.nextval
转载请保持署名和注明原地址

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值