正确使用volatile和synchronized

65 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第9天,点击查看活动详情

在计算中,一个资源可以被不同的线程并发访问。这可能导致数据不一致和损坏。线程ThreadA访问资源并修改它。同时,线程ThreadB开始访问相同的资源。数据可能会被损坏,因为它同时被修改。让我们分析一个没有任何保护的例子:

class PrintDemo {
private int i;
   public void printCount() {
   
      try {
         for (i = 5; i > 0; i--) {
            System.out.println("Selected number is: "  + i );
         }
      } catch (Exception e) {
         System.out.println("Thread has been interrupted.");
      }
   }
}
​
class ThreadDemo implements Runnable {
   private Thread thread;
   private String threadName;
   PrintDemo printDemo;
​
   ThreadDemo(String threadName, PrintDemo printDemo) {
      this.threadName = threadName;
      this.printDemo = printDemo;
   }
   
   public void run() {
      printDemo.printCount();
      System.out.println("Thread " +  threadName + " finishing.");
   }
​
   public void start () {
      System.out.println("Starting " +  threadName);
      if (thread == null) {
         thread = new Thread (this, threadName);
         thread.start ();
      }
   }
}
​
public class Example {
   public static void main(String args[]) {
​
      PrintDemo printDemo = new PrintDemo();
​
      ThreadDemo firstThread = new ThreadDemo("Thread 1", printDemo);
      ThreadDemo secondThread = new ThreadDemo("Thread 2", printDemo);
​
      try {
         firstThread.start();
         secondThread.start();
      } catch( Exception e) {
         System.out.println("Interrupted");
      }
   }
}

如果执行此命令,结果是折衷的且不确定的。每次你都会得到一个不同的随机输出。这是因为每个线程在不同的时刻执行。

Starting Thread 1 output:
Starting Thread 2 output:
Selected number is: 5
Selected number is: 4
Selected number is: 3
Selected number is: 2
Selected number is: 5
Selected number is: 4
Selected number is: 1
Selected number is: 3
Selected number is: 2
Selected number is: 1
Thread Thread 1 finishing.
Thread Thread 2 finishing.

这两个修饰符都处理多线程和保护代码段不受线程访问。我的直觉是synchronized比volatile更广泛地使用和理解,所以我将在文章的开头解释它是如何工作的。稍后我们还需要它来理解volatile的区别。

同步修改器

synchronized修饰符可以应用于语句块或方法。Synchronized通过确保代码的关键部分永远不会由两个不同的线程并发执行来提供保护,从而确保数据的一致性。让我们在前面的例子中应用synchronized修饰符,看看它是如何被保护的:

class PrintDemo {
   public void printCount() {
      try {
         for (int i = 5; i > 0; i--) {
            System.out.println("Selected number is: "  + i );
         }
      } catch (Exception e) {
         System.out.println("Thread has been interrupted.");
      }
   }
}
​
class ThreadDemo implements Runnable {
   private Thread thread;
   private String threadName;
   PrintDemo printDemo;
​
   ThreadDemo(String threadName, PrintDemo printDemo) {
      this.threadName = threadName;
      this.printDemo = printDemo;
   }
   
   public void run() {
    synchronized(printDemo) {
      printDemo.printCount();
    }
      System.out.println("Thread " +  threadName + " finishing.");
   }
​
   public void start () {
      System.out.println("Starting " +  threadName);
      if (thread == null) {
         thread = new Thread (this, threadName);
         thread.start ();
      }
   }
}
​
public class Example {
   public static void main(String args[]) {
​
      PrintDemo printDemo = new PrintDemo();
​
      ThreadDemo firstThread = new ThreadDemo("Thread 1", printDemo);
      ThreadDemo secondThread = new ThreadDemo("Thread 2", printDemo);
​
      try {
         firstThread.start();
         secondThread.start();
      } catch( Exception e) {
         System.out.println("Interrupted");
      }
   }
}

注意,在本例中,我们将synchronized添加到printCount()函数运行的部分。如果你现在执行这个函数,结果总是一样的:

Starting Thread 1
Starting Thread 2
Selected number is: 5
Selected number is: 4
Selected number is: 3
Selected number is: 2
Selected number is: 1
Thread Thread 1 finishing.
Selected number is: 5
Selected number is: 4
Selected number is: 3
Selected number is: 2
Selected number is: 1
Thread Thread 2 finishing.

Volatile 修饰符

我们之前提到过同步修饰符可以应用于块和方法。它们之间的第一个区别是volatile是一个可以应用于字段的修饰符。

关于Java内存和多线程如何工作的一个小说明。当我们在多线程环境中工作时,每个线程都会在它们正在处理的变量的本地缓存中创建自己的副本。当更新此值时,更新首先发生在本地缓存副本中,而不是在实际变量中。因此,其他线程不知道其他线程正在更改的值。

这里volatile改变了范式。当一个变量被声明为volatile时,它将不会存储在线程的本地缓存中。相反,每个线程将访问主存中的变量,而其他线程将能够访问更新后的值。让我们比较一下所有的方法,以便正确理解:

int firstVariable;
int getFirstVariable() {return firstVariable;}
​
volatile int secondVariable;
int getSecondVariable() {return secondVariable;}
​
int thirdVariable;
synchronized int getThirdVariable() {return thirdVariable;}

第一种方法不受保护。线程T1将访问该方法,创建自己的firstVariable本地副本并使用它。同时,T2和T3也可以访问firstVariable并修改它的值。T1, T2和T3将有它们自己的firstVariable值,这些值可能不相同,并且没有被复制到java的主存中,在那里保存真实的结果。

另一方面,getSecondVariable()访问一个声明为volatile的变量。这意味着,每个线程仍然能够访问该方法或块,因为它没有被synchronized保护,但它们都将从主存中访问相同的变量,而不会创建自己的本地副本。每个线程将访问相同的值。

正如我们可以从前面的例子中想象的那样,getThirdVariable()一次只能从一个线程访问。这将确保变量在所有线程执行期间保持同步。

volatile和synchronized的实用性

读完这篇短文,一个问题可能会在你的脑海中出现。我理解理论上的含义,但实际的含义是什么?Synchronized在这一点上可能更容易理解,但是什么时候应用volatile修饰符是有用的?我总是喜欢展示一个例子,以提供更好的理解。

考虑一个Date变量。Date变量总是需要相同的,似乎它的更新是有规律的。每个线程访问一个被声明为volatile的Date变量将始终显示相同的值。

关于线程安全有两个主要方面。一个是执行控制,另一个是内存可见性。volatile提供了内存可见性(所有线程将从主存访问相同的值),但不能保证执行控制,最新的控制只能通过synchronized来实现。

此外,在Java中,对于volatile变量(包括长变量和双变量),所有的读和写操作都是原子的。许多平台分两步执行long和double操作,一次写入/读取32个字节,并允许两个线程看到两个不同的值。这可以通过使用volatile来避免。