geekbang-多线程一基础理论

多线程-基础理论

一.多线程全景图
1.分工:完成一个项目,需要拆分任务,安排合适的成员去完成

	a.Executor与线程池
	b.Fork/Join
	c.Future
	d.Guarded Suspension模式
	e.Balking模式
	f.Thread-Per-Message模式
	g.生产者-消费者模式
	h.Worker-Thread模式
	i.两阶段终止模式

2.协作:一个线程执行完了一个任务,如何通知后续任务的线程开工

	a.信号量Semaphore
	b.管程Monitor
		【Lock&Condition】
		【synchronized】
	c.CountDownLatch
	d.CyclicBarrier
	e.Phaser
	f.Exchanger

3.互斥:同一时刻,只允许一个线程访问共享变量

	a.无锁
		A.不变模式
		B.线程本地存储
		C.CAS
		D.Copy-on-Write
		E.原子类
	b.互斥锁
		A.synchronized
		B.Lock
		C.读写锁

二.并发编程的源头
0.底层诱因:CPU、内存、I/O设备三者速度差异巨大

	1.CPU增加了缓存,以均衡与内存的速度差异
	2.操作系统增加了进程、线程,以分时复用CPU,以均衡CPU与I/O设备速度差异
	3.编译程序优化指令执行次序,使得缓存能够得到更合理的利用CPU、内存、I/O设备三者速度差异巨大

1.缓存导致的可见性问题

	可见性:一个线程对共享变量的修改,另一个线程能够立刻看到,这就是可见性。
	多核时代,每颗CPU都有自己缓存,CPU缓存与内存的数据一致性不容易保证。假设两个线程A,B同时
	修改内存中共享变量V,由于他们与内存之间都有各自的CPU缓存,所以缓存之间的不具备可见性。各个
	CPU启动会存在时差,这个时差会导致两个线程操作共享变量1亿次后出现结果接近1亿。而操作1000次
	会接近2000次。

2.线程切换带来的原子性问题

	1.线程通过获取CPU时间片来进行分时复用提升IO使用率
	2.ex:count+=1;这个代码在高级语言中是一条语句,但是在CPU这个层级确实有3个步骤:
		1.从内存在加载count变量到CPU寄存器;
		2.在CPU寄存器中操作+1;
		3.最后将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存).
	所以两个线程同时操作count+=1时候,可能中途发送时间线程切换。
	这样就破坏了这条高级语言语句的原子性。

3.编译优化带来的有序性问题

	1.编译器为了优化性能,有时候会改变程序中语句的先后顺序,如a=6,b=7;编译器优化后可能变成:
		b=7,a=6;
	2.ex:java中的双重检查创建单例对象,getInstance()。new这个操作:
			1.分配一块内存M;
			2.在内存M上初始化Singletion对象;
			3.然后M的地址赋值给instance变量。
		实际上:1-->3->2.那么在多线程模式下如果发生线程切换,
		可能另一个线程拿到3的instance,那么就有可能触发NPE.
	public class Singleton{
		static Singleton instance;
		static Singleton getInstance(){
		if(instance == null){
			synchronized(Singleton.class){
				if(instance == null) instance = new Singleton();
				}}}}                                   

4.思考题:32位机器上对long型变量进行加减操作存在并发隐患?

long类型是64位的,所在在32位机器上操作需要执行写会被拆分为高32位和低32位写.
多核无法保证原子性,所以存在并发隐患。

三.java内存模型
1.java内存模型

既然编译优化和缓存导致不可见会产生并发隐患,解决就需要按需禁用缓存和编译优化。
Java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。

2.java内存模式提供禁用缓存和编译优化的方法

	1.volatile
		禁用cpu缓存
		volatile int x =0;表达的意思:告诉编译器,对这个变量的读写不使用CPU缓存,
		必须从内存中读取或者写入
	2.synchronized
	3.final
		final修饰变量时,告诉编译器,这个变量生而不变,可以使劲优化。
		逸出
			final int x ;
			public FinalFieldExample(){
				x=3;
				global.obj = this;//此处就是讲this逸出
			}
			构造函数里面将this赋值给了全局变量global.obj,这就是"逸出",
			线程通过global.obj读取x是有可能读到0的。所以应该避免"逸出"。
	4.Hanppens-Before【简称H-B】规则
		前面一个操作的结果对后续操作时可见的。优化了编译器的优化行为。
		1.程序的顺序性规则:
			程序前面对某个变量的修改一定是对于后续操作可见的。
		2.volatile变量规则:
			对一个volatile变量的写操作,H-B于后续这个volatile变量的读操作。
			即:写完一定可以读到,因为都在内存中。
		3.传递性
			如果A H-B B,B H-B C,那么A H-B C.
		4.管程中的锁规则synchronized
			一个锁的解锁H-B于后续对这个锁的加锁。
			即:另一个线程都加锁了,之前的其他的锁一定释放了。
			java中隐式实现锁,即在进入同步块之前,会自动加锁,代码块执行之后自动释放锁,
			加锁和解锁都是编译器帮我们实现的。
		5.线程start()规则
			主线程A启动子线程B后,子线程B能看到主线程在启动子线程B前的操作B.start()之前的任意操作。
		6.线程join()规则
			线程等待:主线程A等待子线程B完成(A调用B.join()),当子线程B完成后
			(主线程A中的join方法返回),主线程能够看到子线程B操作的共享变量。
		7.线程终端规则
			对线程interrupt()方法调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过
			Thread.interrupted()方法检测到是否有中断。
		8.对象终结规则
			一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

四.互斥锁
1互斥锁

	同一个时刻只有一个线程执行,为互斥。一段需要互斥执行的代码称为临界区,
	线程进入临界区之前,首先尝试加锁Lock(),如果成功,则进入临界区,此时我们
	称这个线程持有锁;否则就等待,直到持有锁的线程解锁;持有锁的线程执行完
	临界区的代码后,执行解锁unLock().类比:办公室占坑位一样。

2.锁模型

	锁保护的资源R;
	保护资源R就得创建一把锁LR;
	要注意不要出现自己锁锁他家资产的情况;
	对不同的资源设置不同的锁来保护对应的资源----细粒度锁;
	一把锁可以锁多个资源,即包场----粗粒度锁;

3.java中的synchronized

	1.隐式规则
		当修饰静态方法,锁定的是当前类的Class对象,Class X
		当修饰非静态方法的时候,锁定的是当前实例对象this.
	2.ex:
		synchronized void addOne(this){value+=1;} 
		synchronized long get(){return value;} 
		需要在两个方法都在synchronized才能同时保证get()和addOne()都是互斥的,
		避免了线程安全问题。
	3.受保护的资源和锁之间的关联关系是N:1的关系,
		一把锁可以锁多个资源,即包场。
		不能多个锁锁同一个资源。
		ex: 
			synchronized static void addOne(this){value+=1;}
			synchronized long get(){return value;};
		这样就出现了两把锁this和SafeCalc.class,那么addOne()和get()对于value修改就
		没有可见性了,两个临界区[class和实例this]没有互斥关系。
	4.保护没有关联关系的多个资源
		1.对不同的资源设置不同的锁来保护对应的资源----细粒度锁
		ex:银行业务中针对账号余额(余额是一种资源)的取款操作,也有针对账户密码(密
		码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题。
	5.保护有关联关系的多个资源
		2.创建Account.class共享锁来保护有关联关系的多个资源,对象共享锁----粗粒度锁。
		a.ex:银行转账业务的转账操作,账户A减少100元,账户B增加100元。这两个账户是有关联关系的。
			我们声明账户类Account,成员变量余额balance,转账方法tranfer。如何保证没有并发问题?
				Class Account{
					private int balance;//转账
					void transfer(Account target,int amt){
						if(this.balance>amt){
							this.balance -=amt;
							target.balance+=amt;}}}
		b.如果只是在transfer上面加锁synchronized,则是this锁,没法保证并发安全。
			例如:A,B,C3个账户余额都是200,现在进行线程1A转B100,线程2B转C100,期望结果为
			A100,B200,C300。实际上呢?
				因为线程1锁定的是A.this,线程2锁定的是B.this;所以两个线程可以同时进入临界
				区transfer().读B开始都为200,如1比2快则2覆盖1B100;2比1快则1覆盖2B300.
		c.解决:使用能覆盖所有受保护的资源的锁----包场;Account.class是所有对象共享的,
			而且这个对象是Java虚拟机在加载Account类的时候创建的,所以不用担心唯一性。
				Class Account{
					private int balance;//转账
					void transfer(Account target,int amt){
					synchronized(Account.class){//共享锁
						if(this.balance>amt){
						this.balance -=amt;
						target.balance+=amt;
						}}}}
			问题:这样虽然解决了并发问题,但是整个所有账户转账都是串行的,这个性能太差了。
		d.性能提升:两把锁,一把锁this,一把锁target
				Class Account{
					private int balance;//转账
					void transfer(Account target,int amt){
					synchronized(this){//this锁
						synchronized(target){//target锁
							if(this.balance>amt){
								this.balance -=amt;
								target.balance+=amt;
								}}}}}
			细粒度锁可以提高并行度,是性能优化的一个重要手段,但是有可能会导致死锁。
			假设张三需求业务:A转B100;李四需求业务:B转A100;如果这时候张三拿到了A,
			李四拿到了B,他们相互等待彼此的另一个账本,就会死等,即出现死锁。

五.死锁
1.死锁:一组互相竞争资源的线程因互相等待,导致"永久"阻塞的现象。
2.死锁产生的条件

1.互斥,共享资源X和Y只能被一个线程占用
2.占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
3.不可抢占,其他线程不能强行抢占线程T1的资源
4.循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。

3.死锁的预防

		1.对于"占用且等待"这个条件,我们可以一次性申请所有的资源,这样就不存在等待了
			增加一个账本管理员,然后只允许账本管理员从文件架上拿账本。只有账本管理员
			同时拿到A,B账本才给张三/李四。
			定义一个角色Allocator,方法同时申请资源apply(),同时释放资源free().
				Class Allocator{
					private List<Object> als = new ArraryList<>();
					//一次性申请所有资源
					synchronized boolean apply(Object from,Object to){
					if(als.contains(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 Allocator actr;//actr应该为单例
				private int balance;//转账
				void transfer(Account target,int amt){
					while(!actr.apply(this,target));//等待一次性拿到所有资源
					try{synchronized(this){//this锁
					synchronized(target){//target锁
						if(this.balance>amt){
							this.balance -=amt;
							target.balance+=amt;}}}
						finally{actr.free(this,target);}
						}}
		2.对于"不可抢占"这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,
			可以主动释放它占有的资源,这样不可抢占这个条件就破坏了
			synchronized不能做到,需要使用java.util.concurrent下的Lock
		3.对于"循环等待”这个条件,可以靠按序申请资源来预防。即:资源时有线性顺序的,申请
			的时候可以先申请资源序号小的,再申请资源序号大的,这样线性化后自然就不存在循环了。
			假设每个账户都有不同的属性id,这个id可以作为排序字段,申请的时候,我们可以按照从小
			到大的顺序来申请。
				Class Account{
					private int id;//id作为排序字段
					private int balance;//转账
					void transfer(Account target,int amt){
						Account left = this; //1
						Account right = target;//2
						if(this.id> target.id){//3
							left = target;//4
							right = this;//5
							}//6
						synchronized(left){//锁定序号小的账户
								synchronized(right){//锁定序号大的账户
									if(this.balance>amt){
										this.balance -=amt;
										target.balance+=amt;}}}
						}}

4.死锁的解决

识别出风险很重要,预防死锁主要破坏三个条件中的一个就行了,选择成本最低的解决方案。
上面的例子中:3就比2成本低,虽然while死循环不耗时。

六.等待-通知机制优化循环等待
1.思路:

	如果线程要求的条件(转储账本和转入账本同在文件架上)不满足,则线程阻塞自己,进入等待状态;
	当条件满足,通知等待线程重写执行。使用线程阻塞的方式可以避免循环等待消耗CPU的问题

2.现实中的就医流程

	1.患者先去挂号,然后到就诊门口分诊等待叫号
	2.当叫到自己的号时,患者就可以找大夫就诊了
	3.就诊过程中,大夫可能会让患者去做检查,同时叫下一个患者
	4.当患者做完检查后,拿检测报告重新分诊,等待叫号
	5.当大夫再次叫到自己的号时,患者再去找大夫就诊
优点:一个大夫只为一个患者服务,而且还能够保证大夫和患者的效率

3.结构化分析就医流程

	1.患者到就诊门口分诊,类似于线程要去获取互斥锁;当患者被叫到时,线程已经获取了锁
	2.大夫让患者去做检查(缺乏检测报告不能将诊断病因),类似于线程要求的条件没有满足
	3.患者去做检查,类似于线程进入等待状态;然后大夫叫一下患者,线程释放持有的互斥锁
	4.患者做完检查,类似于线程要求条件全部满足,患者那到检测报告重新分诊,类似重新获取互斥锁。

4.synchronized+wait+notifyAll

	1.synchronized保护的临界区相当于大夫的诊室
	2.当有一个线程进入临界区后,其他线程就只能进入图左边的等待队列等待,类似患者分诊等待

在这里插入图片描述

	3.这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
	4.当一个线程进入临界区后,条件不满足,需要进入等待状态,并释放锁,这个wait()可以满足。
		调wait()方法后,当前线程会被阻塞,并且进入右边的等待队列,这个等待队列也是互斥锁的等
		待队列。线程进入等待队列会释放互斥锁

在这里插入图片描述

	5.当条件满足时会掉notify()/notifyAll()方法,会通知等待队列中的线程,告诉他们条件曾经满足过。
		notify只会保证通知的时间点条件是满足的,而被通知线程的执行时间点和通知的时间点基本不
		会重合,所以当线程执行的时候,条件可能已经不满足了[其他线程插队]。
	6.互斥锁等待队列即synchronized锁定的是this,那么对应的一定是
		this.wait(),this.notify(),this.notifyAll();锁定的是target,那对应的就是
		target.wait(),target.notify(),target.notifyAll().

5.尽量使用notifyAll()

	1.notify()是会随机通知等待队列中的一个线程,而notifyAll()会通知等待队列中的所有线程。
	2.notify()有风险,可能导致某些线程永远不会被通知。

6.经典写法:

	Class Allocator{
		private List<Object> als = new ArraryList<>();
		//一次性申请所有资源
		synchronized void apply(Object from,Object to){
			while(als.contains(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();//通知}
	}

七.安全性,活跃性,性能问题
1.安全性

	1.数据竞争(Data Race): 当多个线程同时访问同一数据,并且至少有一个会写这个数据的时候,
		我们不采取任何防护措施,就会导致并发Bug
	2.竞态条件(Race condition):程序的执行结果依赖线程执行的顺序。
	3.ex:set(get()+1)这个复合操作,其实就是依赖get()的结果

2.活跃性

	1.死锁
		相互等待资源释放,导致程序永远等待阻塞下去。
	2.活锁
		线程没有发生阻塞,仍然会存在执行不下去的情况
		生活中案例
			路人甲从左手边出门,路人乙从右手边进门,路人甲让路,路人乙同时让路,结果一直是两人相撞。
			解决:谦让时,尝试等待一个随机事件就可以了。
	3.饥饿
		线程因无法访问所需资源而无法执行下去的情况。如果线程优先级不均,那么CPU繁忙的时候,
			优先级低的线程就很可能发生"饥饿"
		解决:
			1.保证资源充足
			2.公平的分配资源[**常见]
			3.避免持有锁的线程长时间执行。

3.性能问题

	1.尽量减少串行化范围,以提升性能。
	2.Amdahl阿姆达尔定律
		S=1/[(1-p)+p/n]
		n为cpu核数,p并行百分比,(1-p)就是串行百分比,假设串行百分比为5%,那么加速比s的极限就是20倍。
	3.解决方案
		1.使用无锁的算法和数据结构
			线程本地存储Thread Local Storate
			TLS
			写入时复制 Copy-on-write
			乐观锁
			java并发包中的原子类
			Disruptor无锁内存队列
		2.减少锁持有的时间
			使用细粒度的锁;如java中的ConcurrentHashMap就是使用了分段锁技术
			读写锁;读无锁,写互斥
	4.性能度量指标
		吞吐量
			单位时间内能处理的请求数量。吞吐量越高,性能越好
		延迟
			从发出请求到收到相应的时间。延迟越小,说明性能越好
		并发量
			能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,
			一般都会是基于并发量来说的。例如并发量是1000时,延迟是50毫秒。

八.管程(monitor)
1.管程

	管理共享变量以及对共享变量的操作过程,让他们支持并发。java中的管程模型为MESA模型,
	synchronized,wait(),notify(),notifyAll()都是管程的组成部分。

2.并发编程领域两大核心

	1.管程解决互斥问题
		将共享变量及其共享变量的操作统一封装起来。对外提供线程安全的操作方法。
		ex:管程X将共享变量queue这个线程不安全的队列和相关操作入队enq(),出队deq()都封装起来了;
		线程A和线程B如果想访问共享变量queue,只能通过调用管程提供的enq(),deq()方法来实现;
		enq(),deq()保证互斥性,只允许一个线程进入管程。

在这里插入图片描述

	2.管程解决同步问题
		a.管程模型里,共享变量和对共享变量的操作时被封装起来的。只提供一个入口,入口旁边还有入口
		等待队列。当多个线程同时试图进入管程内部时,只允许一个线程进入,其他线程则在入口等待队
		列中等待。这个过程就类似就医流程的分诊,只允许一个患者就诊,其他患者都在门口等待。

在这里插入图片描述

   	b.管程中引入了条件变量,每个条件变量都对应一个等待队列。
   	c.notify的使用条件
   		1.所有等待线程拥有相同的等待条件
   		2.所有等待线程被唤醒后,执行相同的操作
   		3.只需要唤醒一个线程。
   	d.java中的synchronized修饰的代码块,在编译期会自动生成相关的加锁和解锁代码,
   		但是只支持一个条件变量。

九.java线程
1.java线程的生命周期

	1.线程五态模型
		初始状态
				线程已经被创建,但是还不允许分配CPU运行。这个状态属于编程语言特有的,
				不过这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,
				真正的线程还没有创建。
		可运行状态
				线程可以分配CPU执行,这种状态下,真正的操作系统线程已经被成功创建了,
				所以可以分配CPU执行。
		运行状态
				当有空闲的CPU时,操作系统会将其分配给一个处于运行状态的线程,被分配到
				CPU的线程的状态就转换成了运行状态。
		休眠状态
				运行状态如果调用一个阻塞的API或者等待事件,那么线程的状态就会转换到休眠
				状态,同时释放CPU使用权,休眠状态的线程永远没有机会获得CPU使用权。当等
				待的事件出现了,线程就会从休眠状态转换到可运行状态。
		终止状态
				线程执行完或者出现异常就会进入终止状态,终止状态的线程不会切换到其他任何
				状态,进入终止状态也就意味线程的生命周期结束了。
	2.java线程的六态
		New 初始化状态
				New 与 Runnable 转换
					1.继承Thread对象,重写run()方法创建线程。
					2.实现Runnable接口,重写run()方法创建线程。
					t.start()方法转换到Runnable状态
		Runnable 可运行/运行状态
				Runnable与Blocked转换
					对于synchronized修饰的代码块,只允许一个线程进入,其他线程只能等待,
					这种情况下,等待的线程就会从Runnable转换到Blocked状态。等待线程获
					得synchronized锁时,线程会从Blocked转换到Runnable状态。
		Runnable与Waiting转换
					1.获得synchronized锁的线程调用无参数的Object.wait()方法。
					2.调用无参数的Thread.join()方法。其中的join()是一种线程同步方法,例如
					一个线程对象ThreadA,当调用A.join()的时候,执行这个语句的线程会等待
					thread A执行完,而等待中的这个线程,其状态会从Runnable转换到Waiting
					状态。而当threadA执行完,原来的线程会从Waiting状态转换到Runnable.
					3.调用LockSupport.park()方法,当前线程会阻塞,从Runnable转换到Waiting,调用LockSupport.unpark(Thread thread)可唤醒目标线程,此时线程从Waiting转Runnable
		Runnable与Timed_Waiting转换
					1.调用带超时参数的Thread.sleep(long millis)方法
					2.获得synchronized隐式锁的线程,调用带超时参数的Object.wait(long timeOut)方法。
					3.调用带超时参数的Thread.join(long millis)方法;
					4.调用带超时参数的LockSupport.parkNanos(Object blocker,long deadline)方法
					5.调用带参数的LockSupport.pardUntil(long deadlline)方法。
		Runnable与Terminated转换
					1.线程执行完run()方法后,会自动转换到Terminated状态
					2.执行run()方法的时候异常抛出
					3.stop()--已废弃
						会杀死线程,如果线程持有ReentrantLock锁,被stop()的线程并不会自动调用ReentrantLock的unLock()去释放锁。这就比较危险了,同样的还有suspend()/resume()方法。
					4.interrupt()方法
						仅仅是通知线程,线程有机会执行一些后续操作,同时也可以无视这个通知。
						被interrupt的线程,怎么收到通知?
						异常 Throws InterruptedException异常
						主动检测 isInterrupted()方法检查自己是否被中断
	3.导致休眠状态三个原因
			Blocked 阻塞状态
			Waiting 无时限等待
			Timed_waiting 有时限等待
	4.Terminated 终止状态
	5.多线程Bug诊断
			靠日志,靠线程dump来追踪
			jstack命令或者Java VisualVM工具导出JVM所有线程栈信息。

2.创建多少线程才合适

3.为什么局部变量时线程安全的

10.如何使用面向对象的方法写好并发程序

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值