多线程相关问题(二)

JMM(Java 内存模型)

定义

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

模型详解

jmm内存模型.png
  • JMM主内存
    • 存储Java实例对象
    • 成员变量,类信息,常量,静态变量等
    • 数据共享区,多线程并发操作时会引发线程安全问题
  • JMM工作内存
    • 当前方法的所有本地变量信息,本地变量对其他线程不可见(其实工作内存存储的是主内存的拷贝信息,就算两个线程执行同一段代码,也会各自创建自己线程的本地变量)
    • 字节码行号指示器、Native方法的信息
    • 属于线程私有区域,不存在线程间的安全问题

​ Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

主内存与工作内存的数据存储类型以及操作方式

  • 方法里基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中
  • 引用类型的本地变量:引用存储在工作内存中,实例存储在主内存中
  • 成员变量、static变量、类信息均会被存储在主内存中
  • 主内存共享的方式是线程各拷贝一份数据到工作内存,操作完成后刷新回主内存

JMM(Java 内存模型)如何解决可见性问题

jmm内存与硬件.png

如图所示,我们在对主内存的变量进行操作时,可能其中一个线程的操作在CPU缓存区执行的,另一个则是在主内存中执行的,因此也会导致数据不一致问题,那么JMM如何解决可见性问题的?

1.重排序

在程序处理的时候,为了提高性能,处理器和编译器常常会对指令重排序,那么我们就必须满足以下条件才可以进行重排序

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序(happens-before 原则)

happens-before 原则是判断数据是否存在竞争、线程是否安全的依据

  • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

  • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

  • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

  • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

  • 传递性 A先于B ,B先于C 那么A必然先于C

  • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

  • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

  • 对象终结规则 对象的构造函数执行,结束先于finalize()方法

如果两个操作不满足上述任意一个原则,操作就没有顺序保障,则JVM可以对这两个操作进行重排序操作。如果A happens-before B,则A的操作B是可见的。

2.volatile

volatile是Java虚拟机提供的轻量级的同步机制。

  • 保证被volatile修饰的共享变量对所有线程总是可见的,但是并不保证线程安全性 (比如 i++操作)
  • 禁止指令重排优化

JMM如何保证volatile可见?

  • 当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量刷新到主内存中。
  • 当读取一个volatile变量时,JMM会把该线程对应的工作内存设置为无效。

JMM如何保证禁止指令重排优化?

通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化

内存屏障

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个。

  • 保证特定操作的执行顺序
  • 保证某些变量的内存可见性--强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

3.synchronized关键字

保证对于方法级别或者代码块级别的原子性操作。具体原理可以看上一章的内容 synchronized相关问题。

4.synchronized和volatile的区别

既然这两个关键字都提及到了,我们来对比一下他们之间的区别。

对比内容 synchronized volatile
本质 锁定当前变量,只有当线程可以访问,其他线程被阻塞住,直到该线程完成变量的操作为止 告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存读取
级别 变量,方法,类 变量
特性 既能保证变量的修改可见性,也能保证原子性 仅能实现变量的修改可见性,不能保证原子性
阻塞状态 阻塞 不会阻塞
编译器优化策略 可以被优化 不能被优化

CAS(compare and swap)

首先我们需要了解悲观锁与乐观锁

  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
  • 乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。

CAS一种高效实现线程安全性的方法

  • 支持原子更新操作,适用于计数器,序列发生器等场景
  • 属于乐观锁机制,号称lock-free
  • CAS操作失败时,由开发者决定是继续尝试还是执行别的操作

CAS算法思想

包含三个操作数

  • 内存值 V
  • 预期原值 A
  • 新值 B

执行CAS操作时,先将V与A进行比较,如果相等,则将V更新为B ,否则不做任何操作。

这里可以看JDK中的Unsafe.java实现。

public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

这里的调用了native方法。调用的是不同系统对应的指令方法。

JDK给我们提供了java.util.concurrent.atomic 包。如果我们要做原子操作,我们可以熟悉一下这个包中的各种实现。

CAS ---ABA问题

如果有三个线程同时对一个变量进行操作。

  • 线程1 :将A变为B
  • 线程2:将B变为A
  • 线程3: 将A变为C

假如线程1,2执行在3之前,线程3无法确定A是否被改变过。这个就是CAS比较突出的ABA问题。

java线程池

线程池的五种状态

  • RUNNING:能接受新提交的任务,并且也能处理阻塞队列中的任务。
  • SHUTDOWN: 不再接受新提交的任务,但可以处理存量任务
  • STOP: 不在接受新提交的任务,也不处理存量任务
  • TIDYING: 所有任务已终止
  • TERMINATED: terminated()方法执行后进入的状态

状态转换图

状态转换图.png

Executors五种创建配置

5种线程池.png

ThreadPoolExecutor

ThreadPoolExecutor构造函数以及参数详解

public ThreadPoolExecutor(int corePoolSize,  //核心线程数
                          int maximumPoolSize, //最大线程数
                          long keepAliveTime,  //当线程数大于核心时,这是多余空闲线程在终止之前等待新任务的最长时间
                          TimeUnit unit,  //等待时间的单位
                          BlockingQueue workQueue  //任务等待队列
                          ) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(),   //在执行程序创建新线程时使用的工厂
         defaultHandler //因为已达到线程边界和队列容量,执行被阻止时使用的处理程序。也就是我们的饱和策略
        );
}

任务提交后的执行流程

threadpool流程图.png

ThreadPoolExecutor设计与实现

threadpool工作与实现.png

1.当用户执行execute()或sumbit()方法时,将提交的线程保存到工作队列中也就是图中的WorkQueue。

WorkQueue包括

  • ArrayBlockingQueue:列表形式的工作队列,必须要有初始队列大小,有界队列,先进先出。
  • LinkedBlockingQueue:链表形式的工作队列,可以选择设置初始队列大小,有界/无界队列,先进先出。
  • SynchronousQueue:SynchronousQueue不是一个真正的队列,而是一种在线程之间移交的机制。要将一个元素放入SynchronousQueue中, 必须有另一个线程正在等待接受这个元素. 如果没有线程等待,并且线程池的当前大小小于最大值,那么ThreadPoolExecutor将创建 一个线程, 否则根据饱和策略,这个任务将被拒绝。使用直接移交将更高效,因为任务会直接移交 给执行它的线程,而不是被首先放在队列中, 然后由工作者线程从队列中提取任务. 只有当线程池是无解的或者可以拒绝任务时,SynchronousQueue才有实际价值.
  • PriorityBlockingQueue:优先级队列,有界队列,根据优先级来安排任务,任务的优先级是通过自然顺序或
  • Comparator(如果任务实现了Comparator)来定义的。
  • DelayedWorkQueue:延迟的工作队列,无界队列。

2.提交内部的工作线程池,将每个线程抽象为worker对象。调用java.util.concurrent.ThreadPoolExecutor#runWorker来执行任务。

3.对于内部线程池的管理,每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。当线程池工作量较大的时候,会创建线程,当线程池闲置线程较多时,会按配置销毁线程。
线程工厂

  • DefaultThreadFactory:默认线程工厂,创建一个新的、非守护的线程,并且不包含特殊的配置信息。
  • PrivilegedThreadFactory:通过这种方式创建出来的线程,将与创建privilegedThreadFactory的线程拥有相同的访问权限、 AccessControlContext、ContextClassLoader。如果不使用privilegedThreadFactory, 线程池创建的线程将从在需要新线程时调用execute或submit的客户程序中继承访问权限。
  • 自定义线程工厂:可以自己实现ThreadFactory接口来定制自己的线程工厂方法。

4.如果执行结束,则正确返回。

5.如果线程池处于shutdoun的状态,需要特定的策略或自己实现RejectExecutionHandler。

四种策略

  • AbortPolicy:中止策略。默认的饱和策略,抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
  • DiscardPolicy:抛弃策略。当新提交的任务无法保存到队列中等待执行时,该策略会悄悄抛弃该任务。
  • DiscardOldestPolicy:抛弃最旧的策略。当新提交的任务无法保存到队列中等待执行时,则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将“抛弃最旧的”策略和优先级队列放在一起使用)。
  • CallerRunsPolicy:调用者运行策略。该策略实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者(调用线程池执行任务的主线程),从而降低新任务的流程。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。当线程池的所有线程都被占用,并且工作队列被填满后,下一个任务会在调用execute时在主线程中执行(调用线程池执行任务的主线程)。由于执行任务需要一定时间,因此主线程至少在一段时间内不能提交任务,从而使得工作者线程有时间来处理完正在执行的任务。在这期间,主线程不会调用accept,因此到达的请求将被保存在TCP层的队列中。如果持续过载,那么TCP层将最终发现它的请求队列被填满,因此同样会开始抛弃请求。当服务器过载后,这种过载情况会逐渐向外蔓延开来——从线程池到工作队列到应用程序再到TCP层,最终达到客户端,导致服务器在高负载下实现一种平缓的性能降低。

线程池的大小设定

基于前人的实战经验推导而出

  • 属于计算型程序(CPU密集型) :线程数=CPU核数+1
  • 处理I/O多的程序(I/O密集型) : 线程数=CPU核数*(1+平均等待时间/平均工作时间)

你可能感兴趣的:(多线程相关问题(二))