Java 并发问题和线程同步

x33g5p2x  于2021-10-16 转载在 Java  
字(5.1k)|赞(0)|评价(0)|浏览(464)

欢迎阅读我的 Java 并发教程系列的第五部分。在之前的教程中,我们学习了如何用 Java 编写并发代码。在这篇博文中,我们将研究一些与并发/多线程程序相关的常见问题,并了解如何避免它们。

###并发问题

多线程是一个非常强大的工具,它可以让我们更好地利用系统资源,但是我们在读写多线程共享的数据时需要特别小心。

当多个线程尝试同时读写共享数据时,会出现两种类型的问题——

1.线程干扰错误
1.内存一致性错误

让我们一一了解这些问题。

线程干扰错误(竞争条件)

考虑下面的 Counter 类,它包含一个 increment() 方法,每次调用它时都会将计数加一 -

class Counter {
    int count = 0;

    public void increment() {
        count = count + 1;
    }

    public int getCount() {
        return count;
    }
}

现在,让我们假设多个线程尝试通过同时调用 increment() 方法来增加计数 -

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class RaceConditionExample {

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        Counter counter = new Counter();

        for(int i = 0; i < 1000; i++) {
            executorService.submit(() -> counter.increment());
        }

        executorService.shutdown();
        executorService.awaitTermination(60, TimeUnit.SECONDS);
    
        System.out.println("Final count is : " + counter.getCount());
    }
}

你认为上述程序的结果会是什么?因为我们调用了 increment 1000 次,所以最终计数是 1000 吗?

答案是否定的!只需运行上面的程序并亲自查看输出。它不是产生 1000 的最终计数,而是在每次运行时给出不一致的结果。我在电脑上运行了3次上述程序,输出分别是992、996和993。

让我们深入挖掘程序,了解程序输出不一致的原因-

当一个线程执行 increment() 方法时,会执行以下三个步骤:

1.检索count的当前值

  1. 将检索到的值加 1
  2. 将增加的值存储回 count

现在让我们假设两个线程 - ThreadA 和 ThreadB,按以下顺序执行这些操作 -

  1. ThreadA :检索计数,初始值 = 0
  2. ThreadB :检索计数,初始值 = 0
  3. ThreadA :递增检索值,结果= 1
  4. ThreadB :递增检索值,结果 = 1
  5. ThreadA : 存储增加的值,count 现在是 1
  6. ThreadB : 存储增加的值,count 现在是 1

两个线程都尝试将计数加 1,但最终结果是 1 而不是 2,因为线程执行的操作相互交错。在上述情况下,ThreadA 所做的更新丢失了。

上述执行顺序只是一种可能性。可能有许多这样的命令可以执行这些操作,从而使程序的输出不一致。
当多个线程尝试并发读写一个共享变量,并且这些读写操作在执行中重叠时,那么最终的结果取决于读写发生的顺序,这是不可预测的。这种现象称为Race condition

访问共享变量的代码部分称为 Critical Section

通过同步访问共享变量可以避免线程干扰错误。我们将在下一节中了解同步。

我们先来看看多线程程序中出现的第二种错误——内存一致性错误。

内存一致性错误

当不同线程对相同数据的视图不一致时,就会发生内存不一致错误。当一个线程更新一些共享数据时会发生这种情况,但此更新不会传播到其他线程,并且它们最终使用旧数据。

为什么会这样? 嗯,这可能有很多原因。编译器对您的程序进行多项优化以提高性能。它还可能重新排序指令以优化性能。处理器也尝试优化事物,例如,处理器可能从临时寄存器(其中包含变量的最后读取值)而不是主存储器(具有变量的最新值)读取变量的当前值.

考虑以下示例,该示例演示了内存一致性错误的实际操作 -

public class MemoryConsistencyErrorExample {
    private static boolean sayHello = false;

    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(() -> {
           while(!sayHello) {
           }

           System.out.println("Hello World!");

           while(sayHello) {
           }

           System.out.println("Good Bye!");
        });

        thread.start();

        Thread.sleep(1000);
        System.out.println("Say Hello..");
        sayHello = true;

        Thread.sleep(1000);
        System.out.println("Say Bye..");
        sayHello = false;
    }
}

在理想情况下,上述程序应该 -

1.等待一秒,然后在sayHello变为真后打印Hello World!

  1. 再等一秒,在sayHello变为false后打印Good Bye!
# Ideal Output
Say Hello..
Hello World!
Say Bye..
Good Bye!

但是运行上面的程序后我们得到了想要的输出吗?好吧,如果您运行该程序,您将看到以下输出 -

# Actual Output
Say Hello..
Say Bye..

此外,该程序甚至不会终止。

等待。什么?这怎么可能?

是的!这就是内存一致性错误。第一个线程不知道主线程对 sayHello 变量所做的更改。

您可以使用 volatile 关键字来避免内存一致性错误。我们将很快了解有关 volatile 关键字的更多信息。

同步

通过确保以下两点,可以避免线程干扰和内存一致性错误——

1.一次只有一个线程可以读写共享变量。当一个线程访问共享变量时,其他线程应该等到第一个线程完成。这保证了对共享变量的访问是Atomic,并且多个线程不会干扰。
2.每当任何线程修改共享变量时,它都会自动与其他线程对该共享变量的后续读取和写入建立 happens-before 关系。这保证了一个线程所做的更改对其他人可见。

幸运的是,Java 有一个 synchronized 关键字,您可以使用它来同步对任何共享资源的访问,从而避免这两种错误。

同步方法

以下是 Counter 类的 Synchronized 版本。我们在 increment() 方法上使用 Java 的 synchronized 关键字来防止多个线程同时访问它——

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class SynchronizedCounter {
    private int count = 0;

    // Synchronized Method 
    public synchronized void increment() {
        count = count + 1;
    }

    public int getCount() {
        return count;
    }
}

public class SynchronizedMethodExample {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        SynchronizedCounter synchronizedCounter = new SynchronizedCounter();

        for(int i = 0; i < 1000; i++) {
            executorService.submit(() -> synchronizedCounter.increment());
        }

        executorService.shutdown();
        executorService.awaitTermination(60, TimeUnit.SECONDS);

        System.out.println("Final count is : " + synchronizedCounter.getCount());
    }
}

如果运行上面的程序,它将产生所需的输出 1000。不会发生竞争条件,并且最终输出始终一致。 synchronized 关键字确保一次只有一个线程可以进入 increment() 方法。

请注意,同步的概念始终绑定到一个对象。在上述情况下,在 SynchonizedCounter 的同一实例上多次调用 increment() 方法会导致竞争条件。我们正在防止使用 synchronized 关键字。但是线程可以安全地同时在 SynchronizedCounter 的不同实例上调用 increment() 方法,并且不会导致竞争条件。

在静态方法的情况下,同步与 Class 对象相关联。

同步块

Java 在内部使用所谓的内在锁或监视器锁 来管理线程同步。每个对象都有一个与之关联的内在锁。

当线程调用对象上的同步方法时,它会自动获取该对象的内在锁,并在该方法退出时释放它。即使该方法引发异常,也会发生锁定释放。

在静态方法的情况下,线程获取与类关联的 Class 对象的内在锁,这与类的任何实例的内在锁不同。

synchronized 关键字也可以用作块语句,但与 synchronized 方法不同,synchronized 语句必须指定提供内在锁的对象 -

public void increment() {
    // Synchronized Block - 

    // Acquire Lock
    synchronized (this) { 
        count = count + 1;
    }   
    // Release Lock
}

当一个线程获得一个对象的内在锁时,其他线程必须等到锁被释放。但是,当前拥有锁的线程可以多次获取它,没有任何问题。

允许一个线程多次获取同一个锁的想法称为重入同步

可变关键字

Volatile 关键字用于避免多线程程序中的内存一致性错误。它告诉编译器避免对变量进行任何优化。如果将变量标记为 volatile,编译器将不会围绕该变量优化或重新排序指令。

此外,变量的值将始终从主内存而不是临时寄存器中读取。

以下是我们在上一节中看到的相同 MemoryConsistencyError 示例,不同之处在于,这次我们用 volatile 关键字标记了 sayHello 变量。

public class VolatileKeywordExample {
    private static volatile boolean sayHello = false;

    public static void main(String[] args) throws InterruptedException {

        Thread thread = new Thread(() -> {
           while(!sayHello) {
           }

           System.out.println("Hello World!");

           while(sayHello) {
           }

           System.out.println("Good Bye!");
        });

        thread.start();

        Thread.sleep(1000);
        System.out.println("Say Hello..");
        sayHello = true;

        Thread.sleep(1000);
        System.out.println("Say Bye..");
        sayHello = false;
    }
}

运行上述程序会产生所需的输出 -

# Output
Say Hello..
Hello World!
Say Bye..
Good Bye!

结论

在本教程中,我们了解了多线程程序中可能出现的不同并发问题,以及如何使用 synchronized 方法和块来避免这些问题。同步是一个强大的工具,但请注意,不必要的同步会导致其他问题,例如 deadlockstarvation

相关文章

微信公众号

最新文章

更多