线程安全

定义

1.什么是线程安全

当多个线程 同时去操作 共享资源时 能够得到正确的结果就是线程安全。

2.为什么会有线程安全问题。

由于计算机的CPU运算能力比起和内存的交互能力高几个数量级,为了不浪费CPU的运算能力,所以在主内存和CPU之间增加了一层高速缓存。

java 什么原因会导致线程切换_线程安全

java 什么原因会导致线程切换_线程_02

每次计算前,先从主存中读取数据到高速缓存中,之后的计算就是通过高速缓存。等到最终计算完成后,再通过协议把高速缓存中的结果同步回主内存中。

同样的Java也有直接的内存模型jmm

java 什么原因会导致线程切换_线程安全_03


java 什么原因会导致线程切换_线程_04

其中工作内存是私有的,而主内存是公有的。

比如:现在主内存中有个变量 sss=5;

线程A 把 sss拷贝到 工作内存,进行加 1,
线程B 也把 sss拷贝到自己的工作内存中,进行加1,

然后线程A把结果同步回主内存,此时sss=6;
线程B也把结果同步回主内存,sss=6.

这里就出问题了,SSS 加了2次1,预期结果应该是7,但是实际却是6 就出问题了。

java 什么原因会导致线程切换_线程_05

java 什么原因会导致线程切换_Java_06

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内 存、如何从工作内存同步回主内存之类的实现细节,规定了8种操作,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:

  1. lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来 , 释放后的变量才可以被其他线程锁定unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来 , 释放后的变量才可以被其他线程锁定
  3. read(读取) : 作用于主内存的变量 , 把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用read(读取) : 作用于主内存的变量 , 把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. load(载入):作用域工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中load(载入):作用域工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use (使用) : 作用于工作内存的变量 , 把工作内存中的一个变量值传递给执行引擎use (使用) : 作用于工作内存的变量 , 把工作内存中的一个变量值传递给执行引擎
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量
  7. store (存储) : 作用于工作内存的变量 , 把工作内存中的一个变量的值传送到主内存中 , 以便随后的write的操作
  8. write (写入) : 作用于主内存的变量, 它把store操作从工作内存中一个变量的值传送到主内存的变量中

当然还有一些原则:

  1. 如果要把一个变量从主内存中复制到工作内存, 就需要按顺序地执 行read和load操作 ,如果把变量从工作内存中同步回主内存中, 就要按顺序地执行store和write操作. 但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行
  2. 不允许read和load、 store和write操作之一单独出现
  3. 不允许一个线程丢弃它的最近assign的操作,即变量在工作
    内存中改变了之后必须同步到主内存中
  4. 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  5. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。 即就是对一个变量实施use和store操作之前 , 必须先执行过了assign和load操作
  6. 一个变量在同一时刻只允许一条线程对 其进行lock操作 , 但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  7. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值, 在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  8. 如果一个变量事先没有被lock操作锁定 , 则不允许 对它执行unlock操作 ; 也不允许去unlock一个被其他线程锁定的变量
  9. 对一个变量执行unlock操作之前 , 必须先把此变量同步到主内存中(执行store和write操作)

3.怎么保证线程安全

其实只要满足3个条件就可以做到线程安全:

  1. 可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  2. 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  3. 有序性:即程序执行的顺序按照代码的先后顺序执行。

所以结合上面3个特性和java内存模型,我们有以下办法保证线程安全:

  1. 不共享数据
  2. 使用关键字
  3. 使用JUC帮助类
  4. 原子操作
使用

原始不安全程序:

public static void main(String[] args) {
		for (int i = 0; i < 20; i++) {
			new Thread(new Runnable() {
				
				@Override
				public void run() {
					for (int j = 0; j < 100; j++) {
						sb++;
						try {
							Thread.sleep(10);
						} catch (InterruptedException e) {
							// TODO Auto-generated catch block
							e.printStackTrace();
						}
					}
				}
			}).start();
		}
		println(TAG, sb);
		try {
			Thread.sleep(2000);
		} catch (InterruptedException e) {
			// TODO Auto-generated catch block
			e.printStackTrace();
		}
		println(TAG, sb);
	}

上面的程序中,循环开启20个线程,每个线程对sb自加1。我们预期的结果是2000。 但是每次运行,实际值都会小于2000。这里就发生了上面说的线程不安全问题。sb++这个动作不是原子操作。虚拟机实际执行性的是:

int temp=sb;

temp =temp+1;

sb=temp;

1.使用synchronized关键字

这种方式最简单,要注意的是尽量缩小同步代码块的范围。只加在 操作共享数据的地方就行了。比如:

java 什么原因会导致线程切换_线程安全_07

只要同步sb++这个操作就行了。结果就是2000。

要注意:

  1. 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
  2. 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
  3. 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

2. 使用volatile 关键字

其实单纯使用volatile关键字并不能保证 线程安全,因为volatile只能保证可见性,并不能保证原子性,所以还是不能保证线程安全。所以这里基本没用,一直就只用在单例模式中,和只需要可见性,如停止无限循环的线程。

3 使用原子类AtomicXXX

这些类是在JUC下,1.5提供的类,让我们可以更加简单的写出线程安全代码。例如:

java 什么原因会导致线程切换_线程安全_08

红色框中的代码就可以保证 自加是原子操作。从而保证了线程安全。

4 使用Lock类加锁

Loock是个接口,常用的实现类是ReentrantLock 可重入锁。

java 什么原因会导致线程切换_java_09

要记得释放锁,否则就会死锁 程序异常。

5 使用阻塞队列BlockingQueue

阻塞队列 系统源码中使用的比较多,比如线程池中

java 什么原因会导致线程切换_Java_10

这个适合生产者消费者模型。我们用的比较多的是ArrayBlockingQueue和LinkedBlockingQueue。一个是数组实现,一个是链表实现。 例子:

private static BlockingQueue<Integer> sBlockingQueue=new ArrayBlockingQueue<Integer>(10);
	public static void main(String[] args) {
		sBlockingQueue.add(22);
		sBlockingQueue.add(23);
		new Thread(new Runnable() {
			@Override
			public void run() {
				for (; ; ) {
					try {
						println("take", sBlockingQueue.take());
						Thread.sleep(100);
					} catch (InterruptedException e) {
						e.printStackTrace();
					}
				}
			}
		}).start();
		new Thread(new Runnable() {
			
			@Override
			public void run() {
				try {
					Thread.sleep(2000);
				} catch (InterruptedException e) {
					// TODO Auto-generated catch block
					e.printStackTrace();
				}
				sBlockingQueue.add(12);
				sBlockingQueue.add(22);
			}
		}).start();

运行结果:

java 什么原因会导致线程切换_java_11


第一个线程从队列中取出数据,如果队列没数据就会阻塞 直到有数据。add 和 polll 不会阻塞。put 和 take会阻塞。

6 使用CountDownLatch

是一个同步辅助工具,允许一个或多个线程等待,直到在其他线程中执行的一组操作完成。

举个例子,有三个工人在为老板干活,这个老板有一个习惯,就是当三个工人把一天的活都干完了的时候,他就来检查所有工人所干的活。记住这个条件:三个工人先全部干完活,老板才检查。所以在这里用Java代码设计两个类,Worker代表工人,Boss代表老板,具体的代码实现如下:

工人:

public class Worker extends Thread{
	
	private CountDownLatch downLatch;  
    private String name;  
      
    public Worker(CountDownLatch downLatch, String name){  
        this.downLatch = downLatch;  
        this.name = name;  
    }  
      
    public void run() {  
        this.doWork();  
        try{  
            TimeUnit.SECONDS.sleep(new Random().nextInt(10));  
        }catch(InterruptedException ie){  
        }  
        System.out.println(this.name + "活干完了!");  
        this.downLatch.countDown();  
          
    }  
      
    private void doWork(){  
        System.out.println(this.name + "正在干活!");  
    }  
}

老板:

public class Boss extends Thread{
	
	 private CountDownLatch downLatch;  
     
	    public Boss(CountDownLatch downLatch){  
	        this.downLatch = downLatch;  
	    }  
	      
	    public void run() {  
	        System.out.println("老板正在等所有的工人干完活......");  
	        try {  
	            this.downLatch.await();  
	        } catch (InterruptedException e) {  
	        }  
	        System.out.println("工人活都干完了,老板开始检查了!");  
	    }  
}

测试:

public static void main(String[] args) {
		ExecutorService executor = Executors.newCachedThreadPool();  
        Worker w1 = new Worker(sCountDownLatch,"张三");  
        Worker w2 = new Worker(sCountDownLatch,"李四");  
        Worker w3 = new Worker(sCountDownLatch,"王二");  
        Boss boss = new Boss(sCountDownLatch);  
        executor.execute(w3);  
        executor.execute(w2);  
        executor.execute(w1);  
        executor.execute(boss);  
        executor.shutdown();

java 什么原因会导致线程切换_java 什么原因会导致线程切换_12

怎么运行都是 老板等 3个工人干完活才去检查。

7 使用ThreadLocal

使用这个类是无法保证线程同步的的,因为这个类会为每个线程创建一份数据,所以就不存在共享数据的问题了。

其实这个类应该是没什么用,并不能达到目的。

8 使用CyclicBarrier

这个和前面的CountDownLatch类似

对于CyclicBarrier,假设有一家公司要全体员工进行团建活动,活动内容为翻越三个障碍物,每一个人翻越障碍物所用的时间是不一样的。但是公司要求所有人在翻越当前障碍物之后再开始翻越下一个障碍物,也就是所有人翻越第一个障碍物之后,才开始翻越第二个,以此类推。类比地,每一个员工都是一个“其他线程”。当所有人都翻越的所有的障碍物之后,程序才结束。而主线程可能早就结束了,这里我们不用管主线程。

private static CyclicBarrier sCyclicBarrier=new CyclicBarrier(3);
	
	
	
	
	public static void main(String[] args) {
		
		for (int i = 0; i < sCyclicBarrier.getParties(); i++) {
			new Thread(new Thread05(), "队友"+i).start();
		}
		
		static class Thread05 implements Runnable {
		@Override
		public void run() {
			for(int i = 0; i < 3; i++) {
                try {
                    Random rand = new Random();
                    int randomNum = rand.nextInt((3000 - 1000) + 1) + 1000;//产生1000到3000之间的随机整数
                    Thread.sleep(randomNum);
                    System.out.println(Thread.currentThread().getName() + ", 通过了第"+i+"个障碍物, 使用了 "+((double)randomNum/1000)+"s");
                    sCyclicBarrier.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
		}
	}

运行结果:

java 什么原因会导致线程切换_线程_13

CountDownLatch和CyclicBarrier都有让多个线程等待同步然后再开始下一步动作的意思,但是CountDownLatch的下一步的动作实施者是主线程,具有不可重复性;而CyclicBarrier的下一步动作实施者还是“其他线程”本身,具有往复多次实施动作的特点。

原理

上面说的所有其实都是 JUC下的东西,所以我们重点就是搞清楚,JUC下的东西,那么线程安全就没问题了。

JUC就是 1.5增加的 java.util.concurrent 包下的所有类。

大致可以分为以下几类:

锁类

原子类

阻塞队列类

并发辅助工具类

线程池类

等等。

不过它们的基础是 CAS ,AQS,synchronize,volatile和各种锁。基本结构如下:

java 什么原因会导致线程切换_java 什么原因会导致线程切换_14

1. synchronized的实现原理

原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、 assign、use、store和write。
如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了 lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用, 但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这 两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块 之间的操作也具备原子性。

Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

java 什么原因会导致线程切换_java_15

实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

对象头:由Mark Word 和Class Metedata Address组成。
其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等

默认的存储结构为:

java 什么原因会导致线程切换_Java_16

它还会根据 不同的状态 有不同的结构:

java 什么原因会导致线程切换_java 什么原因会导致线程切换_17

这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

java 什么原因会导致线程切换_线程_18

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

java 什么原因会导致线程切换_线程安全_19

monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因

任何一个对象都有一个monitor与之关联,当一个monitor被某个线程持有之后,该对象将处于锁定状态。同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

synchronized的可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

等待唤醒机制与synchronized
所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

synchronized (obj) {
obj.wait();
obj.notify();
obj.notifyAll();
}

2. volatile的实现原理

volatile的特性:

volatile可见性:对一个volatile的读,总可以看到对这个变量最终的写;

volatile原子性:volatile对单个读/写具有原子性(32位Long、Double),但是复合操作除外,例如:i++;

jvm底层采用“内存屏障”来实现volatile语义。

当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中;

当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量。

java中volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。这个指令就相当于一个内存屏障

3. CAS

CAS的全称是Compare And Swap 即比较交换,其算法核心思想如下

执行函数:CAS(V,E,N)

其包含3个参数

V表示要更新的变量

E表示预期值

N表示新值

如果V值等于E值,则将V的值设为N。若V值和E值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。通俗的理解就是CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作

java 什么原因会导致线程切换_java 什么原因会导致线程切换_20

由于CAS操作属于乐观派,它总认为自己可以成功完成操作,当多个线程同时使用CAS操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作,这点从图中也可以看出来。基于这样的原理,CAS操作即使没有锁,同样知道其他线程对共享资源操作影响,并执行相应的处理措施。同时从这点也可以看出,由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说无锁操作天生免疫死锁。

CPU指令对CAS的支持
或许我们可能会有这样的疑问,假设存在多个线程执行CAS操作并且CAS的步骤很多,有没有可能在判断V和E相同后,正要赋值时,切换了线程,更改了值。造成了数据不一致呢?答案是否定的,因为CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

compareAndSet这个方法主要调用unsafe.compareAndSwapInt这个方法,这个方法有四个参数,其中第一个参数为需要改变的对象,第二个为偏移量(即之前求出来的valueOffset的值),第三个参数为期待的值,第四个为更新后的值。整个方法的作用即为若调用该方法时,value的值与expect这个值相等,那么则将value修改为update这个值,并返回一个true,如果调用该方法时,value的值与expect这个值不相等,那么不做任何操作,并范围一个false。

鲜为人知的指针: Unsafe类

java 什么原因会导致线程切换_java_21

关于Unsafe类的主要功能点如下

java 什么原因会导致线程切换_Java_22


java 什么原因会导致线程切换_java 什么原因会导致线程切换_23

java 什么原因会导致线程切换_java 什么原因会导致线程切换_24

4. AtomicInteger原理

这个类提供int型的原子操作,使用上面已经说的很清楚,这里我们主要看看 它的 原子子操作怎么实现的。

java 什么原因会导致线程切换_Java_25

这里的内存中的偏移量就是字段在 对象中的内存地址。

先看构造方法:

java 什么原因会导致线程切换_java_26

没什么东西,里面有个一个int变量,被volatile修饰。

接着看下它的compareAndSet方法:

java 什么原因会导致线程切换_线程_27

只有一句就是调用unsafe的compareAndSwapInt。

java 什么原因会导致线程切换_线程安全_28

compareAndSet这个方法主要调用unsafe.compareAndSwapInt这个方法,这个方法有四个参数,其中第一个参数为需要改变的对象,第二个为偏移量(即之前求出来的valueOffset的值),第三个参数为期待的值,第四个为更新后的值。整个方法的作用即为若调用该方法时,value的值与expect这个值相等,那么则将value修改为update这个值,并返回一个true,如果调用该方法时,value的值与expect这个值不相等,那么不做任何操作,并范围一个false。

这个方法是原生方法。

基本没有了,可以看到其实 AtomicInteger类 还是很简单的 主要就是 调用了 unsafe提供 原子操作方法。

5. AQS

AbstractQueuedSynchronizer又称为队列同步器(后面简称AQS),它是用来构建锁或其他同步组件的基础框架,内部通过一个int类型的成员变量state来控制同步状态,当state=0时,则说明没有任何线程占有共享资源的锁,当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待,AQS内部通过内部类Node构成FIFO的同步队列来完成线程获取锁的排队工作,同时利用内部类ConditionObject构建等待队列,当Condition调用wait()方法后,线程将会加入等待队列中,而当Condition调用signal()方法后,线程将从等待队列转移动同步队列中进行锁竞争。注意这里涉及到两种队列,一种的同步队列,当线程请求锁而等待的后将加入同步队列等待,而另一种则是等待队列(可有多个),通过Condition调用await()方法释放锁后,将加入等待队列。

java 什么原因会导致线程切换_Java_29

java 什么原因会导致线程切换_线程安全_30

head和tail分别是AQS中的变量,其中head指向同步队列的头部,注意head为空结点,不存储信息。而tail则是同步队列的队尾,同步队列采用的是双向链表的结构这样可方便队列进行结点增删操作。state变量则是代表同步状态,执行当线程调用lock方法进行加锁后,如果此时state的值为0

则说明当前线程可以获取到锁,同时将state设置为1,表示获取成功。如果state已为1,也就是当前锁已被其他线程持有,那么当前执行线程将被封装为Node结点加入同步队列等待。其中Node结点是对每一个访问同步代码的线程的封装,从图中的Node的数据结构也可看出,其包含了需要同步的线程本身以及线程的状态,如是否被阻塞,是否等待唤醒,是否已经被取消等。每个Node结点内部关联其前继结点prev和后继结点next,这样可以方便线程释放锁后快速唤醒下一个在等待的线程,

java 什么原因会导致线程切换_线程安全_31

其中SHARED和EXCLUSIVE常量分别代表共享模式和独占模式,所谓共享模式是一个锁允许多条线程同时操作,如信号量Semaphore采用的就是基于AQS的共享模式实现的,而独占模式则是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待,如ReentranLock。变量waitStatus则表示当前被封装成Node结点的等待状态,共有4种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE。

6. ReentrantLock原理

内部使用了AQS完成,先看Lock方法。

java 什么原因会导致线程切换_Java_32

java 什么原因会导致线程切换_线程_33


java 什么原因会导致线程切换_java 什么原因会导致线程切换_34

java 什么原因会导致线程切换_Java_35

java 什么原因会导致线程切换_java_36

总体流程就是 如果获取锁状态成功,则把当前的线程设置进去,如果失败则 包装为一个node添加到队尾。添加到同步队列后,结点就会进入一个自旋过程,即每个结点都在观察时机待条件满足获取同步状态,然后从同步队列退出并结束自旋,回到之前的acquire()方法,自旋过程是在acquireQueued(addWaiter(Node.EXCLUSIVE), arg))方法中执行的,代码如下

java 什么原因会导致线程切换_线程安全_37

头结点是拥有锁的,正在运行的那一个线程。

7. Condition原理

Condition是接口,有一下方法:

java 什么原因会导致线程切换_线程安全_38

它的实现类在AbstractQueuedSynchronizer中的 ConditionObject。

java 什么原因会导致线程切换_Java_39

7. Semaphore原理

信号量(Semaphore),又被称为信号灯,在多线程环境下用于协调各个线程, 以保证它们能够正确、合理的使用公共资源。信号量维护了一个许可集,我们在初始化Semaphore时需要为这个许可集传入一个数量值,该数量值代表同一时间能访问共享资源的线程数量。线程可以通过acquire()方法获取到一个许可,然后对共享资源进行操作,注意如果许可集已分配完了,那么线程将进入等待状态,直到其他线程释放许可才有机会再获取许可,线程释放一个许可通过release()方法完成。

8. 阻塞队列BolckingQueue原理

阻塞队列与我们平常接触的普通队列(LinkedList或ArrayList等)的最大不同点,在于阻塞队列支出阻塞添加和阻塞删除方法。

阻塞添加
所谓的阻塞添加是指当阻塞队列元素已满时,队列会阻塞加入元素的线程,直队列元素不满时才重新唤醒线程执行元素加入操作。

阻塞删除
阻塞删除是指在队列元素为空时,删除队列元素的线程将被阻塞,直到队列不为空再执行删除操作(一般都会返回被删除的元素)

阻塞队列的主要实现类为:LinkedBlockingQueue与ArrayBlockingQueue

我们先看BlockingQueque接口有哪些方法:

插入方法:

add(E e) : 添加成功返回true,失败抛IllegalStateException异常
offer(E e) : 成功返回 true,如果此队列已满,则返回 false。
put(E e) :将元素插入此队列的尾部,如果该队列已满,则一直阻塞

删除方法:

remove(Object o) :移除指定元素,成功返回true,失败返回false
poll() : 获取并移除此队列的头元素,若队列为空,则返回 null
take():获取并移除此队列头元素,若没有元素则一直阻塞。

检查方法

element() :获取但不移除此队列的头元素,没有元素则抛异常
peek() :获取但不移除此队列的头;若队列为空,则返回 null。

ArrayBlockingQueue的内部是通过一个可重入锁ReentrantLock和两个Condition条件对象来实现阻塞:

java 什么原因会导致线程切换_java_40

从成员变量可看出,ArrayBlockingQueue内部确实是通过数组对象items来存储所有的数据,值得注意的是ArrayBlockingQueue通过一个ReentrantLock来同时控制添加线程与移除线程的并非访问,这点与LinkedBlockingQueue区别很大(稍后会分析)。而对于notEmpty条件对象则是用于存放等待或唤醒调用take方法的线程,告诉他们队列已有元素,可以执行获取操作。同理notFull条件对象是用于等待或唤醒调用put方法的线程,告诉它们,队列未满,可以执行添加元素的操作。takeIndex代表的是下一个方法(take,poll,peek,remove)被调用时获取数组元素的索引,putIndex则代表下一个方法(put, offer, or add)被调用时元素添加到数组中的索引。图示如下

java 什么原因会导致线程切换_Java_41

首先看 put 阻塞添加方法:

java 什么原因会导致线程切换_java_42

很简单如果当前队列已满就等待。

java 什么原因会导致线程切换_线程安全_43

否则把自己添加进队列,然后通知唤醒。

put方法是一个阻塞的方法,如果队列元素已满,那么当前线程将会被notFull条件对象挂起加到等待队列中,直到队列有空档才会唤醒执行添加操作。但如果队列没有满,那么就直接调用enqueue(e)方法将元素加入到数组队列中。到此我们对三个添加方法即put,offer,add都分析完毕,其中offer,add在正常情况下都是无阻塞的添加,而put方法是阻塞添加。这就是阻塞队列的添加过程。说白了就是当队列满时通过条件对象Condtion来阻塞当前调用put方法的线程,直到线程又再次被唤醒执行。总得来说添加线程的执行存在以下两种情况,一是,队列已满,那么新到来的put线程将添加到notFull的条件队列中等待,二是,有移除线程执行移除操作,移除成功同时唤醒put线程,如下图所示

java 什么原因会导致线程切换_线程安全_44

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XQjB88XG-1578466279468)(F3BE9105D20A4699A9C8FFF9D1B44920)]

接下来看看 take 和 poll ;

java 什么原因会导致线程切换_Java_45

区别就是take 会 阻塞等待。

通过上述的分析,对于LinkedBlockingQueue和ArrayBlockingQueue的基本使用以及内部实现原理我们已较为熟悉了,这里我们就对它们两间的区别来个小结

1.队列大小有所不同,ArrayBlockingQueue是有界的初始化必须指定大小,而LinkedBlockingQueue可以是有界的也可以是无界的(Integer.MAX_VALUE),对于后者而言,当添加速度大于移除速度时,在无界的情况下,可能会造成内存溢出等问题。

2.数据存储容器不同,ArrayBlockingQueue采用的是数组作为数据存储容器,而LinkedBlockingQueue采用的则是以Node节点作为连接对象的链表。

3.由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。

4.两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

总结