Java并发编程实践之并发理论基础(一)


一、并发问题的源头

1.1 可见性

可见性是指:一个线程对共享变量修改,另一个线程会立马看到。
缓存导致的可见性问题。
在这里插入图片描述

1.2 原子性

原子性:一个或者多个操作在CPU执行过程中不被中断的特性
线程切换带来的原子性问题。
在这里插入图片描述

1.3 有序性

编译优化带来的有序性问题
以双重校验的单例模式为例

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

new 操作我们期望的流程是:

  1. 分开内存M
  2. 在内存M中初始化Singleton对象
  3. 然后将Singleton对象的地址M赋值给instance变量

但是实际优化后的路径是

  1. 分开内存M
  2. 然后将Singleton对象的地址M赋值给instance变量
  3. 在内存M中初始化Singleton对象

流程如下图所示:
在这里插入图片描述

1.4 小结

主要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发bug都是可以理解、可以诊断的。
缓存、线程、编译优化的目的和我们写并发程序的目的相同,都是提高程序的性能。但是技术在解决一个问题的同时,比如会带来另一个问题,所以我们在采用一项技术的同事,一定要清楚它带来的问题是什么,以及如何规避。

1.5 问题

在32位机器上对long型变量进行加减操作有并发隐患

二、JMM

Java如何解决有序性和可见性问题?TODO 置原子性与何地呢?

2.1 介绍

解决可见性和有序性的直接办法就是禁用缓存和编译优化,但是这样问题虽然解决了,但是性能堪忧。因此合理的方案是 按需禁用缓存和编译优化,那么何时禁止编译优化缓存和编译优化呢?只有程序员知道。因此,为了解决可见性和有序性问题,只需要提供给程序员按需禁用缓存和编译优化的方法即可。
JMM是一个很复杂的规范,可以从不同角度解读,站在程序员角度,本质可以理解为:JMM规范了JVM如何按需提供禁用缓存和编译优化的方法,这些方法包括三个关键字volatile、synchronized和final和6个Happens-before规则。

2.2 volatile

volatile关键字并不是Java语言的特产,古老的C语言也有,最原始的意义是禁用CPU缓存。它表达的是告诉编译器,对这个变量的读写不能使用CPU缓存,必须从内存读取或者写入。从这个语义看上去相当明确,但是在实际使用的时候却会带来困惑。

class VolatileExample{
    
    
	int x = 0;
	volatile boolean v = false;
	public void writer(){
    
    
		x = 42;
		v = true;
	}
	public void reader(){
    
    
		if(v == true){
    
    
			//TODO 这里的X会是多少
		}
	}
}

如果A线程调用writer()方法,将v为true写入内存中,如果B线程调用reader,那么B线程中X的值会是多少? 直觉上来说,应该是42.这个要看JDK版本,如果是1.5之前版本,可能是42,也可能是0。但是如果是1.5之后的版本,x就是42。
分析一下,为什么1.5之前版本可能会出现x=0的情况?变量x可能被CPU缓存而导致可见性问题。在Java1.5版本 对volatile语义进行了增强。通过volatile的hanpens-before。

2.3 Happens-before

Happens-before 并不是说一个操作发生在后续操作的前面,它真正要表达的是前面一个操作的结果对后续操作是可见的。Happens-before规则就是保证线程间操作结果的可见性。
Happens-before约束了编译器的优化行为,虽然允许编译器优化,但是要求编译器优化一定遵循Happens-before规则
Happens-before规则应该是JMM最晦涩的内容,和程序员相关的有如下六项,都是关于可见性的。
1)程序的顺序性原则
在一个线程中,按照程序顺序,前面的操作Happens-Before后续的任意操作
2)volatile原则
volatile变量的写操作Happens-before与后续对这个变量的读操作
3)传递性
如果 A happens-before B , B happens-before C , 则A happens-before C.
4) 管程中的锁规则
对一个锁的解锁happens-before于后续对这个锁的加锁
5)线程start()原则

Thread B = new Thread(()->{
    
    
	//主线程调用B.start()之前,所有对共享变量的修改,此处
	//都是可见的
	//此例中,var = 77
});
//此处对共享变量var进行了修改
var = 77;
//主线程启动子线程
B.start();

6 ) 线程join()原则

Thread B = new Thread(()->{
    
    
	//主线程调用B.start()之前,所有对共享变量的修改,此处
	//都是可见的
	//此例中,var = 77
	var = 100
});
//此处对共享变量var进行了修改
var = 77;
//主线程启动子线程
B.start();
B.join();
//子线程所有对共享变量的修改,在线程调用B.join()之后都是可见
//此例中,var == 66

2.4 final关键字

volatile为的是禁用缓存和编译优化,那有没有办法告诉编译器优化得更好一点呢?那就是final。它修饰变量的初衷就是:这个变量生而不变,可以尽可能的优化

2.5 总结

JMM主要分为两部分,一部分面向写并发程序的应用开发人员,另一部分是面向JVM实现人员的。我们更需要关注和应用开发相关的部分,这部分主要的就是Happens-before规则

2.6 问题

有一个共享变量abc,在一个线程设置了abc=3,有哪些办法可以让其他线程看到"abc == 3"

三、互斥锁(上)

解决原子性问题

原子性:一个或多个操作在CPU执行过程中不被中断的特性。
在32位的机器上,long类型变量的读写,是分为两个步骤的。

3.1 原子性问题该如何解决

原子性问题的源头就是线程切换,如果能够禁止线程切换,就能够解决该问题。操作系统做线程切换依赖的是CPU中断的,所以禁止CPU中断就能够禁止线程切换,在单核CPU时代,这种方案是可行的,但是不合适多核使用场景。我们还是long类型在32位机器上的读写为例:
在这里插入图片描述
在单核CPU上,同一时刻只有一个线程执行,如果禁止CPU中断,这两个写操作能够做到都被执行或者都不被执行,具有原子性,但是在多长CPU下,一个线程CPU-1,禁止了中断,但是不能保证其他核CPU-X执行该段代码。
同一时刻只有一个线程执行,我们称之为”互斥“。也就是说,如果我们能保证对共享变量的修改是互斥的,那么无论在但单核CPU还是多核CPU,就都能够保证原子性了。

3.2 锁模型

3.2.1 简易模型

在这里插入图片描述
互斥的解决方案,你肯定能够想到–锁。我们把一段需要互斥访问代码成为临界区。在进入临界区之前要进行加锁,访问临界区代码需要持有锁,当执行完临界区代码,则需要释放锁。如果想要执行临界区代码,但是没有锁,只能进行等待。

3.2.2 改进后的模型

在这里插入图片描述
改进点在于,我们可以通过针对特定的受保护的资源创建特定的锁。这些很符合现实生活中的场景。

3.3 java中的synchronized

锁是一种通用解决方案,java语言为我们提供了synchronized关键字。
常见的使用方式如下:

class Hello{
    
    
	public synchronized void world(){
    
    
		...
   }

	public static synchronized void doStatic(){
    
    
		...
	}
	
	public void workHard(){
    
    
		Object o = new Object();
		synchronized(o){
    
    
			...
		}
	}
}

synchronized既可以声明在方法上,也可以使用在代码块中。Java编辑器将synchronized修饰代码中添加上 加锁和释放锁相关的代码。从而避免开发者出现错误(没有成对出现)

3.4 用synchronized解决count+1相关的问题

class SafetyCal{
    
    
	long value = 0L;
	long getValue(){
    
    
		return value;
	}
	void synchronized addValue(){
    
    
		value += 1;
	}
}

通过synchronized关键字,保证了addValue的操作原子性。并且通过锁的Happens-before原则,保证了addValue操作对后续的addValue()操作都是可见的。从而保证了1000次addValue调用,其结果肯定是1000.
但是addValue的结果没有对针对getValue的可见性。如果想实现这种可见性,还得需要Happens-before原则,即:

class SafetyCal{
    
    
	...
	long synchronized getValue(){
    
    
		return value;
	}
	...
}

其模型图,如下所示:
在这里插入图片描述

3.5 锁与被保护资源的关系

一个合理的关系是:受保护资源和锁时N:1
如果是多把锁保护同一个资源呢?
在这里插入图片描述
上图中 两个临界区之间没有互斥关系,因此会存在安全性问题。

3.6 总结

互斥锁是并发中核心关注点,但存在并发话题,大家首先都想到加锁。但是,我们必须要深入分析锁对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量,才能用好互斥锁

四、互斥锁(下)

解决一个锁保护多个资源的问题

4.1 保护没有关联关系的多个资源

class Acount{
    
    
	//余额锁
	private Object baLock = new Object();
	//余额
	private Integer balance;
	
	//密码锁
	private Object pwLock = new Object();
	
	private String password;

	public void withDraw(Integer amt){
    
    
		synchronized(baLock){
    
    
			if(this.balance >= amt){
    
    
				this.balance -= amt; 
			}
		}
	}

	public Integer getBalance(){
    
    
		synchronized(baLock){
    
    
			return this.balance;
		}
	}

	public void updatePwd(String newPwd){
    
    
		synchronized(pwLock){
    
    
			this.password = newPwd;
		}
	}

	public String getPwd(){
    
    
		synchronized(pwLock){
    
    
			return this.password;
		}
	}

如上述代码中,我们分别针对密码(password)、余额(balance)使用了不同的锁。这种用不同的锁对资源进行精细化管理能够提升性能,这种锁称为细粒度锁
我们也可以使用一把锁同时保护 密码(password)、余额(balance),这是没有问题的,但是这样做,会使得密码相关操作与余额相关操作存在了互斥性,从而影响效率。

4.2 保护有关联关系的多个资源

那么我们该如何用锁去保护相互关联的资源呢?我们还是以转账操作(账户A转给账户B100元)为例。

class Acount {
    
    
	private Integer balance;
	public void transfer(Acount target,Integer amt){
    
    
		synchronized(this){
    
    
			if(this.balance>=amt){
    
    
				this.balance -=amt;
				target.balance += amt; //这里是安全的吗?答案当然是否定的
			}
		}
	}
}

在上述代码中,锁对象是要转账的对象。但是还对象不能保证target对象的准确性。

4.3 锁的正确使用方式

锁的正确使用姿势:它能够覆盖所有受保护的资源就可以了
根据该原则,4.2节中的正确代码应该是把Account.class对象当做锁即可

class Acount {
    
    
	private Integer balance;
	public void transfer(Acount target,Integer amt){
    
    
		synchronized(Acount.class){
    
    
			if(this.balance>=amt){
    
    
				this.balance -=amt;
				target.balance += amt; //这里是安全的,但是性能不会很理想,那该如何优化呢
			}
		}
	}
}

在这里插入图片描述

4.4 总结

针对如何保护多个资源。首先 要看这些资源是否有关系,如果没有关系,则每个资源一把锁即可;如果有关系,则要一个能够覆盖所有关联资源的锁。然后,还要梳理出有哪些访问路径,并且在访问路径上添加上合适的锁。
关联关系其实就是一种原子性特征。原子性的本质不是不可分割,而是多个资源间有一致性的要求,操作的中间状态对外不可见

五、死锁如何处理

一不小心死锁了,该如何处理?

5.1 模拟现实世界

在现实生活中,转账业务也是并行的,并不是4.3节那样使用Account.class对象作为锁对象,那样效率太差。
我们可以模拟将转账的操作模拟古代没有信息化的转账逻辑。
分成如下三种情况:
a. 转入账本或者转出账本都空闲,则可以直接进行转账操作
b. 只有转入(转出)账本,则需要等待另一个账本
c. 转入和转出账本都没有
在这里插入图片描述

class Account{
    
    
	private int balance;
	void transfer(Account target,int amt){
    
    
		synchronized(this){
    
    
			synchronized(target){
    
    
				if(this.balance > amt){
    
    
					this.balance -= amt;
					target.balance += amt;
				}
			}
		}
	}
}

5.2 细粒度的缺陷

细粒度锁可以提高并行度,是性能优化的一个重要手段。但是细粒度是有代价的,这个代价就是有可能导致死锁
在这里插入图片描述
死锁:一组相互竞争资源的线程因相互等待,导致‘永久’阻塞的现象
在这里插入图片描述

5.3 如何避免死锁

要想避免死锁,我们先来看一下产生死锁的必要条件:

  • 互斥
  • 占有且等待
  • 不可抢占
  • 循环等待
    如果我们能避免其中的某一个条件,就可以避免死锁。
    对于互斥,这个是没有办法破坏。针对占有且等待我们可以通过一次申请所有资源,这样就不会存在等待;针对不可抢占占有部分资源的线程可以进一步申请其他资源,如果申请不到,则需要是否自己已经占有的资源;针对循环等待,我们可以靠按序申请资源,从而破坏循环等待。
class Allocator{
    
    
	private List<Object> als = new ArrayList<Object>();
	synchronized boolean apply(Object from, Object to){
    
    
		if(als.containes(from)||als.contains(to)){
    
    
			return false;
		} else {
    
    
			als.add(from);
			als.add(to);
		}
		return true;
	}
	synchronized void free(Object from,Object to){
    
    
		als.remove(from);
		als.remove(to);
	}
}

class Account{
    
    
	private int balance;
	void transfer(Account target,int amt){
    
    
		//循环监听是是否两个账户可用
		while(!actr,apply(this,target));
		synchronized(this){
    
    
			synchronized(target){
    
    
				if(this.balance > amt){
    
    
					this.balance -= amt;
					target.balance += amt;
				}
			}
		}
	}
}

5.4 总结

当我们在编程世界遇到问题时,应不局限于当下,可以换个思路,向现实世界要答案,利用现实世界模型来构思解决方案,这样往往能够让我们的方案更容易理解,也能够看清问题的本质。

六、等待唤醒机制

用等待唤醒机制优化循环等待?

在上节中,为了获取“两个账户都闲置 的时间点”,采用了死循环 while(!actr,apply(this,target)); 要知道通过死循环是非常消耗性能的
其实在这种场景下,最好的方案应该是:如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程的条件重新满足时,再进行唤醒操作。Java也是存在等待唤醒机制的。
一个完整的等待唤醒机制,首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当线程要求的条件满足时,通知等待线程,重新获取互斥锁

6.1 用Synchronized实现等待-通知机制

在Java语言中,等待通知机制实现方式可以有很多种,比如Java语言内置的synchronized配置wait、notify、notifyAll这三个方法就能轻松实现。
在这里插入图片描述

6.2 优化第五节的分配器

class Allocator{
    
    
	private List<Object> als = new ArrayList<Object>();
	synchronized void apply(Object from, Object to){
    
    
		while(als.containes(from)||als.contains(to)){
    
    
			try{
    
    
				wait();
			}catch(Exception e){
    
    
			}
		} 
		als.add(from);
		als.add(to);
	}
	synchronized void free(Object from,Object to){
    
    
		als.remove(from);
		als.remove(to);
		notifyAll();
	}
}

6.3 尽量使用notifyAll()

**notify()是会随机地通知等待队列中的一个线程,而notifyAll()会通知等待队列中所有的线程。**从感觉上notify应该会好一些,因为即便通知所有的线程,也只有有一个进入临界区。但是所谓的感觉往往都蕴藏着风险,实际上使用notify()也很有风险,它的风险在于可能导致某些线程用于不会被通知到。

假设有ABCD四个资源,线程1申请了AB两个资源,线程2申请了CD两个资源。线程3因无法申请AB而阻塞;线程4因无法申请CD而阻塞。当线程1释放了AB资源,进行notify操作,它有可能唤醒的是线程4,而线程四仍然会因为CD而阻塞。本该执行的线程3无法被唤醒。因此推荐使用notifyAll()

七、安全性、活跃性、及性能问题

并发编程需要注意的问题有很多,主要分为三个方面:安全性、活跃性、性能问题

7.1 安全性

存在共享数据并且该数据会变化,通俗地讲就是有多个线程会同时读写同一数据
数据竞争:当多个线程访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取防护措施,那么就会导致并发bug
竞态条件:程序的结果依赖于线程的执行顺序
面对数据竞争和竞态条件,保证线程安全的方法就是通过互斥

7.2 活跃性

活跃性问题除了死锁,还有活锁和饥饿
有时候线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是是“活锁”,解决活锁问题的就是 随机退让一个时间
线程因无法访问资源儿无法执行下去的情况。因线程优先级较低,永远无法执行到。 解决饥饿问题:1.保证资源充足 2.平均分配 3.避免持有锁的线程执行时间过长

7.3 性能问题

a. 无锁方案
TLS 、Copy on write、乐观锁、JCP中原子类、Disruptor对列
b.减少锁持有的时间
细粒度锁、读写锁
性能相关的指标
* 吞吐量
* 延迟
* 并发量

猜你喜欢

转载自blog.csdn.net/dirksmaller/article/details/108219867