Java并发问题和线程同步

并发问题

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

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

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

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

 

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

考虑下面的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;
import java.util.concurrent.TimeUnit;

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());
    }
}
 

您认为上述程序的结果是什么?因为我们要调用增量1000次,最终计数将为1000吗?

好吧,答案是否定的!只需运行上面的程序,然后自己查看输出即可。而不是产生最终计数1000,而是每次运行都给出不一致的结果。我在计算机上运行了上述程序3次,输出为992、996和993。

让我们更深入地研究该程序,并了解为什么该程序的输出不一致-

当线程执行crement()方法时,将执行以下三个步骤:1.检索count的当前值2.将检索到的值递增1 3.将递增的值存储回count

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

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

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

上面的执行顺序只是一种可能。这些操作可以执行很多这样的顺序,从而使程序的输出不一致。

当多个线程尝试同时读取和写入共享变量,并且这些读取和写入操作在执行中重叠时,最终结果取决于读取和写入发生的顺序,这是无法预测的。这种现象称为竞态条件

代码中访问共享变量的部分称为关键部分

 

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

首先让我们看一下多线程程序中发生的第二种错误-内存一致性错误。

内存一致性错误

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

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

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

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. 等待一秒钟,然后Hello World!在显示为sayHello真后进行打印。
  2. 再等待一秒钟,然后Good Bye!sayHello出现错误之后进行打印。
# Ideal Output
Say Hello..
Hello World!
Say Bye..
Good Bye!

但是,运行上述程序后,我们是否获得了所需的输出?好吧,如果您运行该程序,您将看到以下输出-

# Actual Output
Say Hello..
Say Bye..

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

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

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

您可以使用volatile关键字来避免内存一致性错误。我们将在短期内详细了解volatile关键字。

同步化

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

  1. 一次只能有一个线程读写共享变量。当一个线程正在访问共享变量时,其他线程应等待第一个线程完成。这样可以确保对共享变量的访问是Atomic,并且多个线程不会干扰。

  2. 每当任何线程修改共享变量时,它都会自动与其他线程随后对共享变量的后续读写建立先发生关系。这样可以保证一个线程所做的更改对其他线程可见。

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

同步方法

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

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

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()一次的方法。

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

对于静态方法,同步与Class对象关联。

同步块

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

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

在使用静态方法的情况下,线程获取Class与该类关联的对象的固有锁,这与该类的任何实例的固有锁都不相同。

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

public void increment() {
    // Synchronized Block - 

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

当线程获取对象的固有锁时,其他线程必须等待,直到释放该锁为止。但是,当前拥有该锁的线程可以多次获取它,而不会出现任何问题。

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

 

Volatile关键字

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

同样,变量的值将始终从主存储器而不是临时寄存器中读取。

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

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方法和块避免它们。同步是一个功能强大的工具,但请注意,不必要的同步会导致其他问题,例如死锁饥饿

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值