2.7 线程安全的概念与 synchronized
并行程序开发的一大关注重点就是线程安全。一般来说,程序并行化是为了获得更高的执行效率,但前提是,高效率不能以牺牲正确性为代价。如果程序并行化后,连基本的执行结果的正确性都无法保证,那么并行程序本身也就没有任何意义了。因此,线程安全就是并行程序的根本和根基。大家还记得那个多线程读写long型数据的案例吧!这就是一个典型的反例。但在使用volatile关键字后,这种错误的情况有所改善。但是,volatile并不能真正的保证线程安全。它只能确保一个线程修改了数据后,其他线程能够看到这个改动。但当两个线程同时修改某一个数据时,却依然会产生冲突。
下面的代码演示了一个计数器,两个线程同时对i进行累加操作,各执行10000000次。我们希望的执行结果当然是最终i的值可以达到20000000,但事实并非总是如此。如果你多执行几次下述代码,你会发现,在很多时候,i的最终值会小于20000000。这就是因为两个线程同时对i进行写入时,其中一个线程的结果会覆盖另外一个(虽然这个时候i被声明为volatile变量)。
01 public class AccountingVol implements Runnable{ 02 static AccountingVol instance=new AccountingVol(); 03 static volatile int i=0; 04 public static void increase(){ 05 i++; 06 } 07 @Override 08 public void run() { 09 for(int j=0;j<10000000;j++){ 10 increase(); 11 } 12 } 13 public static void main(String[] args) throws InterruptedException { 14 Thread t1=new Thread(instance); 15 Thread t2=new Thread(instance); 16 t1.start();t2.start(); 17 t1.join();t2.join(); 18 System.out.println(i); 19 } 20 }
图2.8展示了这种可能的冲突,如果在代码中发生了类似的情况,这就是多线程不安全的恶果。线程1和线程2同时读取i为0,并各自计算得到i=1,并先后写入这个结果,因此,虽然i++被执行了2次,但是实际i的值只增加了1。
图2.8 多线程的写入冲突
要从根本上解决这个问题,我们就必须保证多个线程在对i进行操作时完全同步。也就是说,当线程A在写入时,线程B不仅不能写,同时也不能读。因为在线程A写完之前,线程B读取的一定是一个过期数据。Java中,提供了一个重要的关键字synchronized来实现这个功能。
关键字synchronized的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性(也就是说在上述代码的第5行,每次应该只有一个线程可以执行)。
关键字synchronized可以有多种用法。这里做一个简单的整理。
· 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁。
· 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
· 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
下述代码,将synchronized作用于一个给定对象instance,因此,每次当线程进入被synchronized包裹的代码段,就都会要求请求instance实例的锁。如果当前有其他线程正持有这把锁,那么新到的线程就必须等待。这样,就保证了每次只能有一个线程执行i++操作。
public class AccountingSync implements Runnable{ static AccountingSync instance=new AccountingSync(); static int i=0; @Override public void run() { for(int j=0;j<10000000;j++){ synchronized(instance){ i++; } } } //main函数参见本节第一段代码
当然,上述代码也可以写成如下形式,两者是等价的:
01 public class AccountingSync2 implements Runnable{ 02 static AccountingSync2 instance=new AccountingSync2(); 03 static int i=0; 04 public synchronized void increase(){ 05 i++; 06 } 07 @Override 08 public void run() { 09 for(int j=0;j<10000000;j++){ 10 increase(); 11 } 12 } 13 public static void main(String[] args) throws InterruptedException { 14 Thread t1=new Thread(instance); 15 Thread t2=new Thread(instance); 16 t1.start();t2.start(); 17 t1.join();t2.join(); 18 System.out.println(i); 19 } 20 }
上述代码中,synchronized关键字作用于一个实例方法。这就是说在进入increase()方法前,线程必须获得当前对象实例的锁。在本例中就是instance对象。在这里,我不厌其烦地再次给出main函数的实现,是希望强调第14、15行代码,也就是Thread的创建方式。这里使用Runnable接口创建两个线程,并且这两个线程都指向同一个Runnable接口实例(instance对象),这样才能保证两个线程在工作时,能够关注到同一个对象锁上去,从而保证线程安全。
一种错误的同步方式如下:
01 public class AccountingSyncBad implements Runnable{ 02 static int i=0; 03 public synchronized void increase(){ 04 i++; 05 } 06 @Override 07 public void run() { 08 for(int j=0;j<10000000;j++){ 09 increase(); 10 } 11 } 12 public static void main(String[] args) throws InterruptedException { 13 Thread t1=new Thread(new AccountingSyncBad()); 14 Thread t2=new Thread(new AccountingSyncBad()); 15 t1.start();t2.start(); 16 t1.join();t2.join(); 17 System.out.println(i); 18 } 19 }
上述代码就犯了一个严重的错误。虽然在第3行的increase()方法中,申明这是一个同步方法。但很不幸的是,执行这段代码的两个线程都指向了不同的Runnable实例。由第13、14行可以看到,这两个线程的Runnable实例并不是同一个对象。因此,线程t1会在进入同步方法前加锁自己的Runnable实例,而线程t2也关注于自己的对象锁。换言之,这两个线程使用的是两把不同的锁。因此,线程安全是无法保证的。
但我们只要简单地修改上述代码,就能使其正确执行。那就是使用synchronized的第三种用法,将其作用于静态方法。将increase()方法修改如下:
public static synchronized void increase(){ i++; }
这样,即使两个线程指向不同的Runnable对象,但由于方法块需要请求的是当前类的锁,而非当前实例,因此,线程间还是可以正确同步。
除了用于线程同步、确保线程安全外,synchronized还可以保证线程间的可见性和有序性。从可见性的角度上讲,synchronized可以完全替代volatile的功能,只是使用上没有那么方便。就有序性而言,由于synchronized限制每次只有一个线程可以访问同步块,因此,无论同步块内的代码如何被乱序执行,只要保证串行语义一致,那么执行结果总是一样的。而其他访问线程,又必须在获得锁后方能进入代码块读取数据,因此,它们看到的最终结果并不取决于代码的执行过程,从而有序性问题自然得到了解决(换言之,被synchronized限制的多个线程是串行执行的)。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论