`

Java并发之——同步与原子性

 
阅读更多
每一个线程自顾自的做自己的工作固然好。但是线程之间经常会相互影响(竞争或者合作),比如多个线程需要同时操作一个资源(比如一个对象)。这个时候,如果不进行同步,就可能会引发难以预料的错误。

举一个《thinking in java》第四版中的例子。有一个EvenGenerator类,它的next()方法用来生成偶数。如下:
public class EvenGenerator {

	private int currentValue = 0;
	private boolean cancled = false;

	public int next() {
		++currentValue;       //危险!
		++currentValue;
		return currentValue;
	}

	public boolean isCancled() {
		return cancled;
	}
	public void cancle() {
		cancled = true;
	}
}

另外有一个EvenChecker类,用来不断地检验EvenGenerator的next()方法产生的是不是一个偶数,它实现了Runnable接口。
public class EvenChecker implements Runnable {

	private EvenGenerator generator;

	public EvenChecker(EvenGenerator generator) {
		this.generator = generator;
	}

	@Override
	public void run() {
		int nextValue;
		while(!generator.isCancled()) {
			nextValue = generator.next();
			if(nextValue % 2 != 0) {
				System.out.println(nextValue + "不是一个偶数!");
				generator.cancle();
			}
		}
	}
}

然后创建两个EvenChecker来并发地对同一个EvenGenerator对象产生的数字进行检验。
public class Test {

	public static void main(String[] args) {
		EvenGenerator generator = new EvenGenerator();
		Thread t1 = new Thread(new EvenChecker(generator));
		Thread t2 = new Thread(new EvenChecker(generator));

		t1.start();
		t2.start();
	}
}

显然,在一般情况下,EvenGenerator的next()方法产生的数字肯定是一个偶数,因为在方法体里进行两次"++currentValue"的操作。但是运行这个程序,输出的结果竟然像下面这样(并不是每次都是这个一样的结果,但是程序总会因这样的情况而终止):

849701不是一个偶数!

错误出在哪里呢?程序中有“危险”注释的哪一行便可能引发潜在的错误。因为很可能某个线程在执行完这一行只进行了一次递增之后,CPU时间片被另外一个线程夺去,于是就生产出了奇数。

解决的办法,就是给EvenGenerator的next()方法加上synchronized关键字,像这样:

public synchronized int next() {
		++currentValue;
		++currentValue;
		return currentValue;
}

这个时候这个方法就不会在并发环境下生产出奇数了。因为synchronized关键字保证了一个对象在同一时刻,最多只有一个synchronized方法在执行。

synchronized
每一个对象本身都隐含着一个锁对象,这个锁对象就是用来解决并发问题的互斥量(mutex)。要调用一个对象的synchronized方法的线程,必须持有这个对象的锁对象,在执行完毕之后,必须释放这个锁对象,以让别的线程得到这个锁对象。因为一个对象仅有一个锁对象,这就保证了在同一时刻,最多只有一个线程能够调用并执行这个对象的synchronized方法。其他想调用这个对象的synchronized方法的线程必须等待当前线程释放锁。就像上面举的例子,在同一时刻,最多只有一个EvenChecker能调用EvenGenerator的next()方法,这就保证了不会出现currentValue只递增一次,CPU时间片就被别的线程夺去的情况。

再来考虑一下前几天发生的事情。因为日本地震海啸以及核爆炸的缘故,有人造谣说,咱国内已经受到了核污染,吃含碘的东西能够减轻核辐射带来的影响。于是就有投机的人在淘宝上开了一家网店,专卖碘片,一块钱一片。生意十分火爆。有很多个买家不断地在买碘片,一直到把钱给用光。买家买碘片的这些钱都打到了卖家的同一个银行账号里。所以,结果就是,买家所有的钱最后都到了卖家的银行账户里,卖家银行账号里的总额就是所有买家在买碘片之前的现金总计。

所以可以这样来设计类:
/**
 * 银行账户类
 * @author PSJay
 *
 */
public class BankAccount {

	private int total = 0;

	public void add(int n) {
		total += n;
	}

	public int getTotal() {
		return total;
	}
}
/**
 * 买家类
 * @author PSJay
 *
 */
public class Customer implements Runnable{

	private BankAccount account;
	private int cash;

	public Customer(int cash, BankAccount account) {
		this.cash = cash;
		this.account = account;
	}

	public void cost(int n) {
		cash -= n;
		account.add(n);
	}

	@Override
	public void run() {
		while(cash > 0) {  //直至将钱用光
			cost(1);
		}
		System.out.println("total: " + account.getTotal());   //打印出银行账户的总计金额
	}
}
//测试类
public class Test {

	public static void main(String[] args) {

		BankAccount account = new BankAccount();
		for(int i = 0; i < 100; i++) {
			new Thread(new Customer(100000, account)).start();
		}
	}
}

正如代码所示,有100个聪明的向往健康长寿的又有钱的买家各自用10万块不断地狂买碘片。

你可能注意到了,BankAccount类的add()方法并不是synchronized的。为什么呢?因为add()方法里只有一句话,那就是“total += n;”,所以不会出现第一个例子中那样多句话执行到一半被打断的问题。这是正确的么?实践是检验真理的唯一标准,上述程序的某次运行结果如下:

(省略了N行)
total: 7861909
total: 7881906
total: 7995946
total: 8001495
total: 8081441

oops!居然出问题了!最后卖家银行账户里的总额并不是100*100000 = 10000000。看吧,无故少了这么多钱,有谁会愿意去这样的“吞钱”银行开设账户呢?
那么问题究竟出在哪里了呢?BankAccount的add()方法不是只有一句话么,难道这一句话也能被打断?回答就是:这一句话确实能够被打断,因为这样的操作不具有原子性(atomicity),关于原子性稍后再总结。先谈谈怎么解决这个银行吞钱的问题。当然,如上面所说的,给add()方法和getTotal()加上synchronized就行了。

synchronized除了能修饰方法之外,还能创建同步块。如果有时候一个方法里面只有几句话需要同步,你可以考虑这种写法:

public void doSomething() {
     //一些操作
     synchronized(this) {
          //一些需要被同步的操作
     }
     //另外一些操作
}

其中,synchronized后的括号内必须要为一个对象。表示:要执行同步块里的这些操作一定要当前线程取得括号内的这个对象的锁才行。常用的就是this,表示当前对象。在一些高级应用中,可能会用到其他对象的锁。

你也可以显式的使用锁对象来实现同步,Java提供了一些Lock类,本篇总结中不打算包含这些内容。

原子性(atomicity)
具有原子性的操作被称为原子操作。原子操作在操作完毕之前不会线程调度器中断。在Java中,对除了long和double之外的基本类型的简单操作都具有原子性。简单操作就是赋值或者return。比如"a = 1;"和 "return a;"这样的操作都具有原子性。但是在Java中,上面买碘片例子中的类似"a += b"这样的操作不具有原子性,所以如果add方法不是同步的就会出现难以预料的结果。在某些JVM中"a += b"可能要经过这样三个步骤:

1.取出a和b
2.计算a+b
3.将计算结果写入内存
如果有两个线程t1,t2在进行这样的操作。t1在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是t2开始执行,t2执行完毕后t1又把没有完成的第三步做完。这个时候就出现了错误,相当于t2的计算结果被无视掉了。所以上面的买碘片例子在同步add方法之前,实际结果总是小于预期结果的,因为很多操作都被无视掉了。

类似的,像"a++"这样的操作也都不具有原子性。所以在多线程的环境下一定要记得进行同步操作。
有一些并发大牛可以利用原子性避免同步而写出“免锁”的代码。Goetz开玩笑说:

如果你能编写出一个牛逼的高性能的JVM,你就可以考虑考虑是否可以避免使用同步。

所以,在成为这样牛的大牛之前,还是老老实实使用同步吧。

Java SE引入了原子类,比如AtomicInter,AtomicLong等等。

volatile
上面提到了,对long和double的简单操作不具有原子性。但是,一旦给这两个类型的属性加上volatile修饰符,对它们的简单操作就会具有原子性(当然这是说的在Java SE5之后的故事)。

在一些情况下即便是原子操作也可能会引发一些错误,特别是在多处理器的环境下。因为多处理器的计算机可以将内存中的值暂时储存在寄存器或者本地内存缓冲区中。所以,运行在不同处理器上的线程取同一个内存位置的值可能不相同。有一些编译器也会自作主张地优化指令,使得上述情况发生。你当然可以用同步锁来解决这些问题,不过volatile也能解决。

如果给一个变量加上volatile修饰符,就相当于:每一个线程中一旦这个值发生了变化就马上刷新回主存,使得各个线程取出的值相同。编译器不要对这个变量的读、写操作做优化。

但是值得注意的是,除了对long和double的简单操作之外,volatile并不能提供原子性。所以,就算你将一个变量修饰为volatile,但是对这个变量的操作并不是原子的,在并发环境下,还是不能避免错误的发生!比如在碘片例子中,将BankAccount类写成这样:
public class BankAccount {

	private volatile int total = 0;

	public void add(int n) {
		total += n;
	}

	public int getTotal() {
		return total;
	}
}

即便total被volatile修饰,但是由于add方法不是同步的,所以不能避免错误的发生!
分享到:
评论

相关推荐

    Java并发编程之——Amino框架

    Java并发编程是一个复杂而重要的主题,它涉及到多线程、同步机制、线程池和并发容器等关键概念。Amino框架是Java并发编程领域的一个工具,它旨在简化并发编程,提高程序的性能和可维护性。这篇博客文章可能详细探讨...

    JAVA内存模型——同步操作规则1

    在Java并发编程中,这些规则确保了内存可见性和有序性,防止了数据竞争和脏读等并发问题。开发者可以通过synchronized关键字、volatile关键字、Atomic类等方式利用JMM来实现线程安全。理解并遵循这些规则是编写正确...

    并发编程——原子操作CAS.pdf

    总而言之,这份文档是学习Java并发编程中原子操作的宝贵资源。它不仅为读者提供了理论知识,而且通过丰富的代码示例和实践演示,帮助读者深入理解并掌握原子操作的使用。对于那些希望在多线程编程中保证数据一致性和...

    java 并发编程实践

    本篇文章将深入探讨Java并发编程的相关知识点,主要基于提供的两个文件——"Java并发编程实战(中文版).pdf"和"Java Concurrency in Practice.pdf"。 1. **线程与并发** - **线程基础**:Java中的线程是并发执行...

    Java并发性编程(阿里巴巴)培训ppt

    总结起来,Java并发编程包括了线程的创建和管理、同步机制、可见性保证、锁与条件变量、原子操作、并发集合以及线程池等多个方面。这份阿里巴巴的培训材料应该详细讲解了这些主题,并给出了实际应用示例和最佳实践,...

    java并发编程经典书籍(英文版)

    Java并发编程是Java开发者必须掌握的关键技能之一,尤其是在多核处理器和分布式系统广泛使用的今天。以下是对标题和描述中提及的两本经典书籍——《Concurrent Programming in Java》和《Java Concurrency in ...

    Java并发编程:volatile关键字解析

    ### Java并发编程:volatile关键字解析 #### 一、内存模型的相关概念 在深入了解`volatile`关键字之前,我们首先需要理解计算机内存模型的一些基本概念。在现代计算机系统中,CPU为了提高执行效率,会将频繁访问的...

    通过多线程编程在Java中发现的并发模式和特性——线程、锁、原子等.zip

    在Java编程中,多线程是处理并发执行的关键技术,它允许程序同时执行多个任务,提高了系统的效率和响应性。本资料主要探讨了Java中的并发模式和特性,...通过不断实践和学习,可以在Java并发编程领域达到更高的水平。

    并发编程——线程基础.pdf

    总的来说,Java中的并发编程是一门综合技术,它涉及对线程生命周期的理解、线程间通信与协作、线程同步以及并发控制等多方面知识。通过学习并发编程,可以增强处理多任务的能力,提高程序的响应性和吞吐量。这对于...

    Java并发编程图册Java并发编程图册

    Java并发编程是Java开发中的重要领域,它涉及到多线程、同步机制、线程池以及并发集合等核心概念。在Java编程中,理解和熟练掌握并发编程可以极大地提高程序的执行效率,同时避免出现数据不一致性和线程安全问题。本...

    java编程——Java线程

    为了确保线程安全,开发者需要深入理解Java的并发控制机制,合理使用同步策略,如使用`synchronized`关键字、`Lock`接口以及原子操作类等。 #### 结论 Java线程是Java编程中一个强大且必要的特性,它极大地扩展了...

    java虚拟机并发编程

    为了更好地理解Java并发编程的实际应用,我们可以考虑一个简单的例子——使用`Future`和`Callable`来实现异步任务的执行。假设有一个复杂的计算任务需要长时间执行,但主程序不能等待这个任务完成。此时,可以将计算...

    10 有福同享,有难同当—原子性.pdf

    本章将通过“有福同享,有难同当—原子性”这一主题,深入探讨并发编程中的核心概念——原子性,以及与之相关的线程安全和并发编程的三大特性。 并发编程的三大特性包括原子性、可见性和有序性。这些特性是理解和...

    java高并发编程推荐超好的一本电子书

    Java并发模型 Java提供了丰富的并发支持库,包括`java.util.concurrent`包中的各种类和接口,如`ExecutorService`、`Future`、`CountDownLatch`等。 #### 3. 线程安全 线程安全是并发编程中非常重要的一个概念,指...

    14、深入理解并发可见性、有序性、原子性与JMM内存模型(1).pdf

    根据提供的文档信息,本文将详细解析并发编程中的关键概念——原子性、可见性及有序性,并结合Java内存模型(JMM)来深入理解这些概念。同时,我们也会通过具体示例来探讨这些问题在实际编程中的应用。 ### 一、并发...

    Java并发理论,如何理解线程安全.docx

    总之,理解和掌握Java并发理论,包括线程安全、通信与同步、JMM以及并发工具,对于编写高效、稳定的多线程应用程序至关重要。开发者应关注并发编程的三大属性:原子性、有序性和可见性,并利用Java提供的并发机制来...

    《java并发编程艺术》

    《Java并发编程艺术》这本书是Java开发者深入理解多线程...通过阅读《Java并发编程艺术》,开发者可以深入了解Java并发编程的细节,掌握在实际开发中高效、安全地利用多线程的技能,从而提升应用程序的性能和稳定性。

    JAVA并发编程与高并发解决方案-并发编程四之J.U.C之AQS.docx

    《JAVA并发编程与高并发解决方案-并发编程四之J.U.C之AQS》是一篇详细介绍Java实用并发工具包(Java Util Concurrency,简称J.U.C.)中重要组成部分——AbstractQueuedSynchronizer(简称AQS)的文章。AQS是Java并发...

    操作系统实验 多线程同步与互斥 java编写 有界面

    2. **监视器锁**:每个对象都有一个与之关联的内置锁,也称为监视器锁。当线程调用一个对象的`synchronized`方法时,它会获取该对象的锁,其他线程试图调用该对象的任何`synchronized`方法或同步代码块时,都会被...

Global site tag (gtag.js) - Google Analytics