Java并发编程学习笔记之二—并发问题的源头

在随着计算机会硬件的发展,多核处理器已经很常见,为了平衡内存、CPU和IO之前的之间的差距,多线程技术发展迅速,而随即产生了并发问题。了解计算机的内存模型和和java的内存模型后,缓存引发的可见性问题就变得明朗了(Java内存模型及三大特性)。

简单回x顾下Java内存模型的三大特性:

原子性(Atomicity):

原子代表不可切割的最小单位。原子性是指一个操作或多个操作要么全部执行,且执行的过程不会被任何因素打断,要么就都不执行。利用事务中的原子性举例说明:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证不出现一些意外的问题。

再来看看下面的例子:

1、a=1; 

2、a=b; 

3、i++; 

1、中只有赋值的动作,具体有原子性 ;2、中b有读取,a有赋值的动作,b是一个变量,如果此时有其他线程修改b的值,那么这个操作的是不具有原子性的。 3、中有对i进行读取,计算,写入的操作,在多线程情况,i的最终值可能不是你想要的,因为其原子遭到破坏。 上面的例子我们从另一个角度分析可以知道原子性涉及到,一个线程执行一个复合操作的时候,其他线程是否能够看到中间的状态、或进行干扰。这也是判断是否符合具体操作是否符合原子性操作的一种思路。 那么问题来了如何保证原子性操作, 在Java中提供了多种原子性保障措施。

这里主要涉及三种:

● 通过synchronized关键字定义同步代码块或者同步方法保障原子性。

● 通过Lock接口保障原子性。

● 通过Atomic类型保障原子性。

可见性(Visibility):

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

三种保证可见性的操作:

  • volatile:volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
  • synchronized:synchronized关键字在释放锁之前,必须先把此变量同步回主内存中(执行store、write操作)。
  • final: 被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。简单的说就是final修饰的变量,一旦完成初始化,就不能改变。  

有序性:

有序性主要涉及了指令重排序现象和“工作内存与主内存同步延迟”现象。总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

如何保证有序性:Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,

volatile关键字本身就包含了禁止指令重排序的语义,

synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。

从源头上了解并发问题,势必会有更清楚的认识问题的根本。那么产生并问题究竟是什么原因呢?

1)并发问题源头一缓存带来的可见性问题

缓存是为了解决IO和内存之前的速度差异,而缓存带来的可见性问题,成为并发问题的主要原因之一。在单核情况下的缓存一致性问题很容易得到解决,但是到了多核情况就会就没那么简单。

回想主内存和工作内存(高速缓存)的关系:

在Java内存模型中,规定了所有变量都存在主内存里,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。通过工作内存、主内存、线程三者的关系,主内存主要是存储变量,线程间变量的传递,工作内存主要负责缓存了存储变量的副本,对变量进行读取,运算,赋值,最后把变量刷新的主内存。

   下面再通过代码验证一下:

package cn.com.thread;
/**
 * 线程演示类
 * @author Administrator
 *
 */
public class Test1 {
 
	//全局变量count
	private long count = 0;
	
	public static void main(String[] args) {
		try {
			Test1.calc();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	private void add10K(){
		int idx = 0;
		while(idx++ < 100000 ){
			count+=1;
		}
	}
	
	public static long calc() throws InterruptedException{
		final Test1 test1 = new Test1();
		//创建两个线程执行add()操作
		Thread th1 = new Thread(()->{
			test1.add10K();
		});
		Thread th2 = new Thread(() ->{
			test1.add10K();
		});
		
		//启动两个线程
		 th1.start();
		 th2.start();
		 //等待两个线程执行结果
		 th1.join();
		 th2.join();
		 System.out.println(test1.count);
		 
		return test1.count;
	}
}

线程执行示意图:

假设线程A和B同时执行,首先读count的初始化值都是0,读到各自的cpu缓存也是0,在执行conut+=1后,各自cpu的count变为1,而写回主内存后,内存中也是1,而不是2。再进行下次循环时,都是从各自的缓存中读取值,所以最后count值并不是我们想想的20000。并且随着循环次数的增加,最终的值会更偏移我们的理想值。 

2)并发问题源头之二线程切换产生的有序性问题

在这之前先来了解下线程切换的过程:操作系统允许执行了某个程序一小段时间后,例如50毫秒,50毫秒之后去执行另外一个程序。这个过程被称为任务切换,这个50毫秒称之为时间片。再来具体描述下:例如一个时间片内,一个进程正在进行io操作,读取一个文件。当正在读取文件时,该进程可以把自己标记为休眠状态,并让出cpu的使用权,待文件读取完成后,操作系统重新唤醒线程,这样就可以再次拥有cpu的使用权。在了解的任务切换的过程后,接下来的一点也是造成无法保证原子性的原因。

 

java属于高级语言,我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count+=1最少需要三条计算机指令完成。到这里相信对于原子性的问题理解的也更透彻了。

指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;

指令 2:之后,在寄存器中执行 +1 操作;

指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。(程序是和缓存打交道)。

看下线程执行的示意图:

 如图,在a线程执行完指令1以后发生线程切换,线程B开始执行,那么最后写入内存的仍然是1而不是2。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。线程的切换可以发生在count+=1之前也可以发生在之后。

3)并发问题源头之三编译优化带来的有序性问题:

代码的编写顺序并不一定的代码的执行顺序。编译器为了优化性能,有时候会改变程序中语句的先后顺序,例如程序中:“a=6;b=7;”编译器优化后可能变成“b=7;a=6;”,在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果。

代码如下:

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

代码执行步骤:

假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。 这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,

我们以为的 new 操作应该是:

分配一块内存 M;

在内存 M 上初始化 Singleton 对象;

然后 M 的地址赋值给 instance 变量。

但是实际上优化后的执行路径却是这样的:

分配一块内存 M;

将 M 的地址赋值给 instance 变量;

最后在内存 M 上初始化 Singleton 对象。

优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。

 

流程图:

总结:出现并发问题主要原因是

缓存一致性产生的可见性问题;

线程切换产生的原子性问题;

编译优化带来的有序性问题。这三种原因并不是独立出现的,基本所有的并发场景下的问题,经过抽丝剥茧的分析依然就会归属到这三点里边。所以真正理解这三点确实是很重要的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值