同步一个方法

本节介绍如何使用 synchronized 关键字来控制对一个方法的并发访问。 只有一个执行线程将访问一个对象中被声明为 synchronized 的方法中的一个。 如果其他的线程要访问同一对象的任一声明为 synchronized 的方法,将被挂起直到第一个线程结束方法的执行。

也就是说,所有声明为 synchronized 的方法全体是一个临界区,Java只允许一个对象的一个临界区执行。

静态方法的行为不一样。只有一个执行线程将访问声明为 synchronized 的静态方法中的一个,但是其他线程可访问该类对象的其他非静态方法。 小心!因为2个线程能访问不同的 synchronized 方法,如果一个是静态另一个不是静态方法时。如果两个方法改变同一个数据,会有数据不一致的错误。

任务

1个银行账号和2个线程;一个线程存钱进账号而另一个取出。 如果没有同步方法,我们可能会有不正确的结果。同步机制保证了最终账目正确。

实现

本节的示例代码在 com.elanzone.books.noteeg.chpt2.sect02 package中

  • 数据类 (Account)

    • balance 属性及其 getter/setter 方法 : double 类型,账户余额
        private double balance;
    
    • addAmount : 增加账户余额
      只有一个线程能改变账户余额,因此使用 synchronized 关键字将其转为临界区; 为了便于观察是否同时允许了临界区代码,在此方法中 sleep 一段时间模拟耗时操作;
        public synchronized void addAmount(double amount) {
            double tmp = balance;
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            tmp += amount;
            balance = tmp;
        }
    
    • subtractAmount : 减少账户余额
      只有一个线程能改变账户余额,因此使用 synchronized 关键字将其转为临界区;
        public synchronized void subtractAmount(double amount) {
            double tmp = balance;
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            tmp -= amount;
            balance = tmp;
        }
    
  • 线程类 Bank : 模拟 ATM 机不断减少账户余额

    • account属性及构造方法 : Account 类型
        private Account account;
    
        public Bank(Account account) {
            this.account = account;
        }
    
    • run 方法 : 调用 100 次 account 的 subtractAmount 方法减少账户余额
            for (int i = 0; i < 100; i++) {
                account.subtractAmount(1000);
            }
    
  • 线程类 Company : 模拟公司不断增加账户余额

    • account属性及构造方法 : Account 类型
        private Account account;
    
        public Company(Account account) {
            this.account = account;
        }
    
    • run 方法 : 调用 100 次 account 的 addAmount 方法增加账户余额
        for (int i = 0; i < 100; i++) {
            account.addAmount(1000);
        }
    
  • 控制类 (Main)

    • 创建一个 Account 对象并设置 balance 的初始值为 1000
        Account account = new Account();
        account.setBalance(1000);
    
    • 创建一个 Company 对象及对应的线程
        Company company = new Company(account);
        Thread companyThread = new Thread(company);
    
    • 创建一个 Bank 对象及对应的线程
        Bank bank = new Bank(account);
        Thread bankThread = new Thread(bank);
    
    • 输出 balance 的初始值并启动线程
        System.out.printf("Account : Initial Balance: %f\n", account.getBalance());
        companyThread.start();
        bankThread.start();
    
    • 使用 join 方法等待 2 个线程结束并输出 account 的 balance 的最终值
        try {
            companyThread.join();
            bankThread.join();
            System.out.printf("Account : Final Balance: %f\n", account.getBalance());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    

工作原理

本例中,程序调用了 100 次 addAmount,每次增加 1000 账户余额;调用了 100 次 subtractAmount 方法每次减少 1000 账户余额。 可以预期最终和初始余额是一样的。

例子中还尝试模拟了一种错误场景。 它使用了 tmp 临时变量来保存账户余额,增加临时变量的值,然后再设置账户的余额。并且还用 sleep 方法使正在执行此方法的线程睡上 10 毫秒。 这样如果另外一个线程执行了此方法,它将修改账户的余额,从而引发错误。 是 synchronized 关键字机制避免了此错误。

如果您想看到并发访问共享数据的问题,可以删掉 addAmount 和 subtractAmount 方法的 synchronized 关键字再运行此程序。 如果没有 synchronized 关键字,当一个线程在读取了账户余额后睡眠时,另一个方法将读取账户余额,这样两个方法将修改同样的余额,其中一个操作将不会体现在最终结果中。

如果多次运行此程序,每次获得的结果可能会不一样。 JVM不保证线程的执行顺序。这样每次您允许时,线程将以不同的顺序读取、修改账户余额,这样最终结果将不一样。 您可以加上 synchronized 关键字再多跑几次,您将看到每次都会输出确定的结果。

使用 synchronized 关键字,能保证在并发应用中对共享数据正确地访问。

如本节中所提到的,只有一个线程能访问一个对象的使用 synchronized 关键字声明的方法。 如果一个线程(A)在执行一个 synchronized 方法,另一个线程(B)希望执行同一个对象的其他 synchronized 方法,将被阻塞直到线程(A)结束。 但是如果线程(B)访问的是同一个类的另一个对象,则相互之间没有影响,谁也不会被阻塞。

了解更多

  • synchronized 关键字对应用程序的性能不利
    只在并发环境中要修改共享数据的方法上使用它。

    • 如果您有多个线程调用一个 synchronized 方法,同时只有一个将执行,其他的将等待。
    • 如果操作不使用 synchronized 关键字,所有线程能同时执行此操作,减少了总的执行时间。
    • 如果您知道一个方法不会被多于一个线程调用,就别使用 synchronized 关键字。
  • 您可以递归调用 synchronized 方法
    当线程能够访问一个对象的 synchronized 方法,它也能调用该对象的其他 synchronized 方法,包括在执行的方法本身。 它不必再次获得访问 synchronized 方法的权限。

  • 可使用 synchronized 关键字来保护对一段代码块的访问,而不是整个方法。
    使用此方法保护对共享数据的访问,让其余操作在此代码块外,能获得更好的性能。此方法的目标是让临界区尽可能的短。 此方法需要拿一个对象引用作为参数。只有一个线程能访问该对象的 synchronized 代码(代码块或方法)。通常引用的是执行此方法的对象。

    synchronized (this) {
        // Java code
    }