Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

同步 #24

Open
cnwutianhao opened this issue Mar 5, 2024 · 0 comments
Open

同步 #24

cnwutianhao opened this issue Mar 5, 2024 · 0 comments
Labels

Comments

@cnwutianhao
Copy link
Owner

在多线程应用中,两个或者两个以上的线程需要共享对同一个数据的存取。如果两个线程存取相同的对象,并且每一个线程都调用了修改该对象的方法,这种情况通常被称为竞争条件。竞争条件最容易理解的例子如下:比如电影院售卖电影票,电影票数量是一定的,但卖电影票的窗口到处都有,每个窗口就相当于一个线程。这么多的线程共用所有的电影票资源,如果不使用同步是无法保证其原子性的。在一个时间点上,两个线程同时使用电影票资源,那其取出的电影票是一样的(座位号一样),这样就会给顾客造成麻烦。解决方法如下:当一个线程要使用电影票这个资源时,我们就交给它一把锁,等它把事情做完后再把锁给另一个要用这个资源的线程。这样就不会出现上述情况了。

一、重入锁与条件对象

重入锁 ReentrantLock 就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。用 ReentrantLock 保护代码块的结构如下所示:

Lock lock = new ReentrantLock();
lock.lock();
try {
    ...
} finally {
    lock.unlock();
}

这一结构确保任何时刻只有一个线程进入临界区,临界区就是在同一时刻只能有一个任务访问的代码区。一旦一个线程封锁了锁对象,其他任何线程都无法进入 Lock 语句。把解锁的操作放在 finally 中是十分必要的。如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远被阻塞。进入临界区时,却发现在某一个条件满足之后,它才能执行。这时可以使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程,条件对象又被称作条件变量。通过下面的例子来说明为何需要条件对象。假设一个场景需要用银行转账。我们首先写了银行的类,它的构造方法需要传入银行账户的数量和每个账户的账户金额。

public class Bank {

    private double[] accounts;

    private Lock bankLock;

    public Bank(int number, double money) {
        accounts = new double[number];
        bankLock = new ReentrantLock();
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = money;
        }
    }
}

接下来我们要转账,写一个转账的方法,from 是转账方,to 是接收方,amount 是转账金额,如下所示:

public void transfer(int from, int to, int amount) {
    bankLock.lock();
    try {
        while (accounts[from] < amount) {
            // wait
        }
    } finally {
        bankLock.unlock();
    }
}

结果我们发现转账方余额不足;如果有其他线程给这个转账方再转足够的钱,就可以转账成功了。但是这个线程已经获取了锁,它具有排他性,别的线程无法获取锁来进行存款操作,这就是我们需要引入条件对象的原因。一个锁对象拥有多个相关的条件对象,可以用 newCondition 方法获得一个条件对象,我们得到条件对象后调用 await 方法,当前线程就被阻塞了并放弃了锁。整理以上代码,加入条件对象,代码如下所示:

public class Bank {

    private double[] accounts;

    private Lock bankLock;

    private Condition condition;

    public Bank(int number, double money) {
        accounts = new double[number];
        bankLock = new ReentrantLock();
        // 得到条件对象
        condition = bankLock.newCondition();
        for (int i = 0; i < accounts.length; i++) {
            accounts[i] = money;
        }
    }

    public void transfer(int from, int to, int amount) {
        bankLock.lock();
        try {
            while (accounts[from] < amount) {
                // 阻塞当前线程,并放弃锁
                condition.await();
            }
        } finally {
            bankLock.unlock();
        }
    }
}

一旦一个线程调用 await 方法,它就会进入该条件的等待集并处于阻塞状态,直到另一个线程调用了同一个条件的 signalAll 方法时为止。当另一个线程转账给我们此前的转账方时,只要调用 condition.signalAll(),就会重新激活因为这一条件而等待的所有线程。代码如下所示:

public void transfer(int from, int to, int amount) {
    bankLock.lock();
    try {
        while (accounts[from] < amount) {
            // 阻塞当前线程,并放弃锁
            condition.await();
        }

        // 转账的操作
        accounts[from] = accounts[from] - amount;
        accounts[to] = accounts[to] + amount;
        condition.signalAll();
    } finally {
        bankLock.unlock();
    }
}

当调用 signalAll 方法时并不是立即激活一个等待线程,它仅仅解除了等待线程的阻塞,以便这些线程能够在当前线程退出同步方法后,通过竞争实现对对象的访问。还有一个方法是 signal,它则是随机解除某个线程的阻塞。如果该线程仍然不能运行,则再次被阻塞。如果没有其他线程再次调用 signal,那么系统就死锁了。

二、同步方法

Lock 和 Condition 接口为程序设计人员提供了高度的锁定控制,然而大多数情况下,并不需要那样的控制,并且可以使用一种嵌入到 Java 语言内部的机制。从 Java 1.0 版开始,Java 中的每一个对象都有一个内部锁。如果一个方法用 synchronized 关键字声明,那么对象的锁将保护整个方法。也就是说,要调用该方法,线程必须获得内部的对象锁。也就是如下代码:

public synchronized void method() {
}

等价于:

Lock lock = new ReentrantLock();
public void method() {
    lock.lock();
    try {
        ...
    } finally {
        lock.unlock();
    }
}

对于上面银行转账的例子,我们可以将 Bank 类的 transfer 方法声明为 synchronized,而不是使用一个显式的锁。内部对象锁只有一个相关条件,wait 方法将一个线程添加到等待集中,notifyAll 或者 notify 方法解除等待线程的阻塞状态。也就是说 wait 相当于调用 condition.await(),notifyAll 等价于 condition.signalAll()。上面例子中的 transfer 方法也可以这样写:

public synchronized void transfer(int from, int to, int amount) throws InterruptedException {
    while (accounts[from] < amount) {
        wait();
    }

    // 转账的操作
    accounts[from] = accounts[from] - amount;
    accounts[to] = accounts[to] + amount;
    notifyAll();
}

可以看到使用 synchronized 关键字来编写代码要简洁很多。当然要理解这一代码,你必须要了解每一个对象有一个内部锁,并且该锁有一个内部条件。由该锁来管理那些试图进入 synchronized 方法的线程,由该锁中的条件来管理那些调用 wait 的线程。

三、同步代码块

上面我们说过,每一个 Java 对象都有一个锁,线程可以调用同步方法来获得锁。还有另一种机制可以获得锁,那就是使用一个同步代码块,如下所示:

synchronized(obj) {
}

其获得了 obj 的锁,obj 指的是一个对象。再来看看 Bank 类,我们用同步代码块进行改写:

public class Bank {

    private double[] accounts;

    private Object lock = new Object();

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

    public void transfer(int from, int to, int amount) {
        synchronized (lock) {
            // 转账的操作
            accounts[from] = accounts[from] - amount;
            accounts[to] = accounts[to] + amount;
        }
    }
}

在这里创建了一个名为 lock 的 Object 类,为的是使用 Object 类所持有的锁。同步代码块是非常脆弱的,通常不推荐使用。一般实现同步最好用 java.util.concurrent 包下提供的类,比如阻塞队列。如果同步方法适合你的程序,那么请尽量使用同步方法,这样可以减少编写代码的数量,减少出错的概率。如果特别需要使用 Lock/Condition 结构提供的独有特性时,才使用 Lock/Condition。

四、volatile

有时仅仅为了读写一个或者两个实例域就使用同步的话,显得开销过大;而 volatile 关键字为实例域的同步访问提供了免锁的机制。如果声明一个域为 volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。再讲到 volatile 关键字之前,我们需要了解一下内存模型的相关概念以及并发编程中的3个特性:原子性、可见性和有序性。

  1. Java 内存模型

    Java 中的堆内存用来存储对象实例,堆内存是被所有线程共享的运行时内存区域,因此,它存在内存可见性的问题。而局部变量、方法定义的参数则不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。Java 内存模型定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。需要注意的是本地内存是 Java 内存模型的一个抽象概念,其并不真实存在,它涵盖了缓存、写缓冲区、寄存器等区域。Java 内存模型控制线程之间的通信,它决定一个线程对主存共享变量的写入何时对另一个线程可见。

    Java 内存模型的抽象示意图如图1所示:

    线程 A 与线程 B 之间若要通信的话,必须要经历下面两个步骤:

    (1)线程 A 把线程 A 本地内存中更新过的共享变量刷新到主存中去。

    (2)线程 B 到主存中去读取线程 A 之前已更新过的共享变量。

    由此可见,如果我们执行下面的语句:

    int i = 3;
    

    执行线程必须先在自己的工作线程中对变量 i 所在的缓存行进行赋值操作,然后再写入主存当中,而不是直接将数值3写入主存当中。

  2. 原子性、可见性和有序性

    (1)原子性

    对基本数据类型变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行完毕,要么就不执行。现在看一下下面的代码,如下所示:

    x = 3;   // 语句1
    y = x;   // 语句2
    x++;     // 语句3

    在上面3个语句中,只有语句1是原子性操作,其他两个语句都不是原子性操作。语句2虽说很短,但它包含了两个操作,它先读取 x 的值,再将 x 的值写入工作内存。读取 x 的值以及将 x 的值写入工作内存这两个操作单拿出来都是原子性操作,但是合起来就不是原子性操作了。语句3包括3个操作:读取 x 的值、对 x 的值进行加1、向工作内存写入新值。通过这3个语句我们得知,一个语句含有多个操作时,就不是原子性操作,只有简单地读取和赋值(将数字赋值给某个变量)才是原子性操作。java.util.concurrent.atomic 包中有很多类使用了很高效的机器级指令(而不是使用锁)来保证其他操作的原子性。例如 AtomicInteger 类提供了方法 incrementAndGet 和 decrementAndGet,它们分别以原子方式将一个整数自增和自减。可以安全地使用 AtomicInteger 类作为共享计数器而无须同步。另外这个包还包含 AtomicBoolean、AtomicLong 和 AtomicReference 这些原子类,这仅供开发并发工具的系统程序员使用,应用程序员不应该使用这些类。

    (2)可见性

    可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。当一个共享变量被 volatile 修饰时,它会保证修改的值立即被更新到主存,所以对其他线程是可见的。当有其他线程需要读取该值时,其他线程会去主存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,并不会立即被写入主存,何时被写入主存也是不确定的。当其他线程去读取该值时,此时主存中可能还是原来的旧值,这样就无法保证可见性。

    (3)有序性

    Java 内存模型中允许编译器和处理器对指令进行重排序,虽然重排序过程不会影响到单线程执行的正确性,但是会影响到多线程并发执行的正确性。这时可以通过 volatile 来保证有序性,除了 volatile,也可以通过 synchronized 和 Lock 来保证有序性。我们知道,synchronized 和 Lock 保证每个时刻只有一个线程执行同步代码,这相当于是让线程顺序执行同步代码,从而保证了有序性。

  3. volatile 关键字

    当一个共享变量被 volatile 修饰之后,其就具备了两个含义,一个是线程修改了变量的值时,变量的新值对其他线程是立即可见的。换句话说,就是不同线程对这个变量进行操作时具有可见性。另一个含义是禁止使用指令重排序。

    这里提到了重排序,那么什么是重排序呢?重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。

    下面我们来看一段代码,假设线程1先执行,线程2后执行,如下所示:

    // 线程1
    boolean stop = false;
    while (!stop) {
        // 执行业务代码
    }
    
    // 线程2
    stop = true;

    很多开发人员在中断线程时可能会采用这种方式。但是这段代码不一定会将线程中断。虽说无法中断线程这个情况出现的概率很小,但是一旦发生这种情况就会造成死循环。为何有可能无法中断线程?在前面我提到每个线程在运行时都有私有的工作内存,因此线程1在运行时会将stop变量的值复制一份放在私有的工作内存中。当线程2更改了 stop 变量的值之后,线程2突然需要去做其他的操作,这时就无法将更改的 stop 变量写入主存当中,这样线程1就不会知道线程2对 stop 变量进行了更改,因此线程1就会一直循环下去。当 stop 用 volatile 修饰之后,那么情况就变得不同了,当线程2进行修改时,会强制将修改的值立即写入主存,并且会导致线程1的工作内存中变量 stop 的缓存行无效,这样线程1再次读取变量 stop 的值时就会去主存读取。

  4. volatile 不保证原子性

    我们知道 volatile 保证了操作的可见性,下面我们来分析 volatile 是否能保证对变量的操作是原子性的。现在先阅读以下代码:

    public class VolatileTest {
    
        public volatile int inc = 0;
    
        public void increase() {
            inc++;
        }
    
        public static void main(String[] args) {
            final VolatileTest test = new VolatileTest();
            for (int i = 0; i < 10; i++) {
                new Thread() {
                    public void run() {
                        for (int j = 0; j < 1000; j++) {
                            test.increase();
                        }
                    }
                }.start();
            }
    
            // 如果有子线程就让出资源,保证所有子线程都执行完
            while (Thread.activeCount() > 2) {
                Thread.yield();
            }
            System.out.println(test.inc);
        }
    }

    这段代码每次运行,结果都不一致。在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加 1、写入工作内存。也就是说,自增操作的 3 个子操作可能会分割开执行。假如某个时刻变量 inc 的值为9,线程1对变量进行自增操作,线程1先读取了变量 inc 的原始值,然后线程1被阻塞了。之后线程2对变量进行自增操作,线程2也去读取变量 inc 的原始值,然后进行加1操作,并把10写入工作内存,最后写入主存。随后线程1接着进行加1操作,因为线程1在此前已经读取了 inc 的值为9,所以不会再去主存读取最新的数值,线程1对 inc 进行加1操作后 inc 的值为10,然后将10写入工作内存,最后写入主存。两个线程分别对 inc 进行了一次自增操作后,inc 的值只增加了1,因此自增操作不是原子性操作,volatile 也无法保证对变量的操作是原子性的。

  5. volatile 保证有序性

    volatile 关键字能禁止指令重排序,因此 volatile 能保证有序性。volatile 关键字禁止指令重排序有两个含义:一个是当程序执行到 volatile 变量的操作时,在其前面的操作已经全部执行完毕,并且结果会对后面的操作可见,在其后面的操作还没有进行;在进行指令优化时,在 volatile 变量之前的语句不能在 volatile 变量后面执行;同样,在 volatile 变量之后的语句也不能在 volatile 变量前面执行。

  6. 正确使用 volatile 关键字

    synchronized 关键字可防止多个线程同时执行一段代码,那么这就会很影响程序执行效率。而 volatile 关键字在某些情况下的性能要优于 synchronized。但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。通常来说,使用 volatile 必须具备以下两个条件:

    (1)对变量的写操作不会依赖于当前值。

    (2)该变量没有包含在具有其他变量的不变式中。

    第一个条件就是不能是自增、自减等操作,上文已经提到 volatile 不保证原子性。关于第二个条件,我们来举一个例子,它包含了一个不变式:下界总是小于或等于上界,代码如下所示:

     public class NumberRanger {
    
         private volatile int lower, upper;
    
         public int getLower() {
             return lower;
         }
    
         public int getUpper() {
             return upper;
         }
    
         public void setLower(int lower) {
             if (lower > upper) {
                 throw new IllegalArgumentException();
             }
             this.lower = lower;
         }
    
         public void setUpper(int upper) {
             if (upper < lower) {
                 throw new IllegalArgumentException();
             }
             this.upper = upper;
         }
     }

    这种方式将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全。如果当两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),在同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),虽然这两个操作交叉存入的值是不符合条件的,但是这两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4 ,3)。这显然是不对的,因此使用 volatile 无法实现 setLower 和 setUpper 操作的原子性。

    使用 volatile 有很多种场景,这里介绍其中的两种。

    (1)状态标志

     volatile boolean shutdownRequested;
    
     public void shutdown() {
         shutdownRequested = true;
     }
    
     public void doWork() {
         while (!shutdownRequested) {
             ...
         }
     }

    如果在另一个线程中调用 shutdown 方法,就需要执行某种同步来确保正确实现 shutdownRequested 变量的可见性。但是,使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。在这里推荐使用 volatile,状态标志 shutdownRequested 并不依赖于程序内的任何其他状态,并且还能简化代码。因此,此处适合使用 volatile。

    (2)双重检查模式(DCL)

     public class Singleton {
    
         private volatile static Singleton instance = null;
    
         public static Singleton getInstance() {
             if (instance == null) {
                 synchronized (Singleton.class) {
                     if (instance == null) {
                         instance = new Singleton();
                     }
                 }
             }
             return instance;
         }
     }

    getInstance 方法中对 Singleton 进行了两次判空,第一次是为了不必要的同步,第二次是只有在 Singleton 等于 null 的情况下才创建实例。在这里用到了 volatile 关键字会或多或少地影响性能,但考虑到程序的正确性,牺牲这点性能还是值得的。DCL 的优点是资源利用率高,第一次执行 getInstance 方法时单例对象才被实例化,效率高。其缺点是第一次加载时反应稍慢一些,在高并发环境下也有一定的缺陷(虽然发生的概率很小)。

  7. 小结

    与锁相比,volatile 变量是一种非常简单但同时又非常脆弱的同步机制,它在某些情况下将提供优于锁的性能和伸缩性。如果严格遵循 volatile 的使用条件,即变量真正独立于其他变量和自己以前的值,在某些情况下可以使用 volatile 代替 synchronized 来简化代码。然而,使用 volatile 的代码往往比使用锁的代码更加容易出错。在前面的第6小节中介绍了可以使用 volatile 代替 synchronized 的最常见的两种用例,在其他情况下我们最好还是使用 synchronized。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant