Java如何解决可见性和有序性问题?

java内存模型

是一个复杂的规范。定义线程如何通过内存交互。主要包括两部分

  1. 站在程序员角度可理解为,java内存模型规范了按需禁用缓存和编译优化的方法,包括volatile,synchronized和final三个关键字,以及Happens-Before规则。
  2. 面相JVM的实现人员

final关键字:初衷是告诉编译器,此生而变量不变,可随意优化。

Happens-Before

前面一个操作的结果对后续操作是可见的,不是说执行先后顺序。

约束编译器的优化行为,允许编译器优化,但是要求优化后遵守Happens-Before规则

Happens-Before六项规则

  1. 程序的顺序性规则
    同一个线程中,按照程序顺序,前面的操作Happens-Before于后续的任意操作。

  2. volatile 变量规则
    volatile变量的写操作Happens-Before于后续对这个volatile变量的读操作。
    volatile变量的写操作相对于后续对这个volatile变量的读操作可见,这怎么看都是禁用缓存的意思啊。关联规则3

  3. 传递性
    A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class VolatileExample {
    int x = 0;
    volatile boolean v = false;
    public void writer() {
    x = 42;//1
    v = true;//2
    }
    public void reader() {
    if (v == true) {//3
    // 这里 x 会是多少呢?//4
    }
    }
    }

    1HappensBefore2,2HappensBefore3,则得到1HappensBefore4则输出是42

  4. 管程中锁的规则(sychronized锁规则)
    管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是Java里对管程的实现。
    此规则指:对一个锁的解锁Happens-Before于后续对这个锁的加锁

  5. 线程start()规则
    指主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。

1
2
3
4
5
6
7
8
9
Thread B = new Thread(()->{
// 主线程调用 B.start() 之前
// 所有对共享变量的修改,此处皆可见
// 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
  1. 线程join()规则
    指主线程A等待子线程B完成,当子线程B完成后主线程能够看到子线程对共享变量的操作。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    Thread B = new Thread(()->{
    // 此处对共享变量 var 修改
    var = 66;
    });
    // 例如此处对共享变量修改,
    // 则这个修改结果对线程 B 可见
    // 主线程启动子线程
    B.start();
    B.join()
    // 子线程所有对共享变量的修改
    // 在主线程调用 B.join() 之后皆可见
    // 此例中,var==66

并发程序幕后

矛盾:CPU、内存、I/O设备的速度差异

为了合理利用CPU的高性能,平衡三者差异,计算机体系结构,操作系统,编译程序做出了贡献:

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

以上也是并发问题的根源所在。

源头一:缓存导致的可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,称为可见性
多核CPU,每个CPU有自己的缓存。并发执行i++,线程A在CPU1中执行,线程B在CPU2中执行,线程A对CPU2的缓存不具备可见性,就会导致并发问题。
并发执行1000次结果会小于2000。并发执行次数越高1亿次回趋近1亿而不是2亿,因为两个线程不是同时启动的,有一个时差。

源头二:线程切换带来的原子性问题

我们把一个或者多个操作在CPU执行的过程中不被中断的特性称为原子性。
CPU能保证的原子操作是CPU指令级别的,而不是高级语言的操作符,
高级语言里一条语句往往需要多条 CPU 指令完成,如:
count += 1
需三条指令:

  1. 把变量 count 从内存加载到 CPU 的寄存器
  2. 在寄存器中执行 +1 操作
  3. 将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)

如下图,期望结果是1而不是2。

源头三:编译优化带来的有序性问题

DCL问题

1
2
3
4
5
6
7
8
9
10
11
12
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}

new操作是问题所在,我们以为的new操作:

  1. 分配一块内存M;
  2. 在内存M上初始化Singleton对象;
  3. 然后M的地址赋值给instance变量。
    指令优化后的却是:
  4. 分配一块内存M;
  5. 将M的地址赋值给instance变量;
  6. 最后在内存M上初始化Singleton对象。

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

总结

缓存带来了可见性问题,线程切换带来了原子性问题,编译优化带来了有序性问题。三者提高程序性能,解决一个问题的同时带来另外问题,所以采用一项技术的同时,要清楚它带来的问题是什么,以及如何规避。

volatile :禁止指令重排,禁用缓存保证可见性。
实现原理:内存屏障
四种屏障类型:LoadLoad,StoreStore,LoadStore,StoreLoad。
重排规则:

  1. 第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. 第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

问题

  • 在 32 位的机器上对 long 型变量进行加减操作存在并发隐患,到底是不是这样呢?
    long是64位,32位操作系统字长是32位,一次加减操作分成了高32位和低32位操作,两个cpu指令操作不能保证原子性

  • CPU 与内存、I/O 的速度差异的优化引发可见性、原子性和有序性问题
  • Java 内存模型和互斥锁方案解决三个问题
  • 互斥锁是解决并发问题的核心工具,会引发死锁
  • 死锁的原因、解决方案 以及 “等待-通知”机制 (synchronized,wait,notify(),notifyAll)
  • 宏观的角度重新审视并发问题:安全性、活跃性、性能
  • 管程,解决线程的互斥与同步问题;管程模型
  • 线程生命周期、内部如何执行、计算线程数
  • 面向对象编写并发程序
1
2
logger.debug("The var1:{}, var2:{}", var1, var2);
logger.debug("The var1:" + var1 + ", var2:" + var2);

占位符写法仅仅是将参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。

12如何用面向对象思想写好并发程序

  • 封装共享变量
  • 识别共享变量间的约束条件
  • 制定并发访问策略

封装共享变量

将共享变量作为对象属性封装在内部
不变的量可用final修饰,避免并发问题

识别共享变量约束条件

比如数值型的上下限,上限不能小于下限
反映在代码里,基本上都会有if语句,此时注意竞态条件

制定并发访问策略

  1. 避免共享
    利于线程本地存储以及为每个任务分配独立的线程
  2. 不变模式
    Java领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor模式、CSP模式以及函数式编程的基础都是不变模式
  3. 管程及其他同步工具
    Java领域万能解决方案:管程,但是对于很多特定场景,使用 Java并发包提供的读写锁、并发容器等同步工具会更好

写出健壮并发程序的宏观原则

  1. 优先使用成熟的工具类
    JavaSDK并发包里提供了丰富的工具类,自己造轮子要小心
  2. 小心使用低级的同步原语
    synchronized、Lock、Semaphore等并不简单
  3. 避免过早优化
    安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。

调用栈:CPU通过堆栈寄存器调用方法
调用栈保存方法的栈帧,栈帧包含方法参数、局部变量、返回地址
局部变量和方法同生共死,若变量想跨越方法边界需要创建在堆里。
线程有独立的调用栈,保存自己的局部变量,所以局部变量不会有并发问题
没有共享,没有伤害

线程封闭仅在单线程内访问数据(方法的局部变量不和其他线程共享,没有并发问题)

创建多少线程合适

要解决这个问题,首先要分析以下两个问题:

  1. 为什么要使用多线程?
  2. 多线程的应用场景有哪些?

为什么使用多线程

为了快,快如何度量?
两个核心指标:延迟、吞吐量(时间维度、空间维度)
延迟:发出请求到收到响应这个过程的时间
吞吐量:单位时间内能处理请求的数量

提升性能就需从降低延时、提升吞吐量思考。

多线程使用场景

两个方向降低延迟、提升吞吐量:

  1. 优化算法
  2. 将硬件性能发挥到极致
    并发编程领域提升性能本质是提升硬件的利用率,即提升I/O利用率和CPU综合利用率

假设程序按照CPU计算和I/O操作交叉执行
CPU计算和I/O操作的耗时是1:1
单线程时CPU计算时I/O空闲,CPU和I/O利用率都是50%
2个线程,CPU和I/O利用率100%,单位时间处理请求量翻倍,吞吐量提升一倍
以上反推,CPU和I/O利用率很低时可尝试增加线程提升吞吐量

单核,多线程可平衡CPU和I/O设备。若程序只有CPU计算,多线程会增加线程切换使性能更差。
多核,纯CPU计算程序可利用多核并行计算,多线程提升性能。。

创建多少线程合适

一般程序是CPU计算和I/O计算交叉执行
I/O计算时间相对来说很长称为I/O密集型,相反CPU密集型

CPU密集型

线程的数量 = CPU核数 +1
当线程因为偶尔的内存页失效或其他原因导致阻塞时,额外的线程可以顶上,从而保证CPU利用率。

I/O密集型

最佳线程数 = CPU核数 * [ 1 +(I/O耗时 / CPU耗时)]

总结

以上给出了理论上的线程数,可将硬件性能发挥到极致。
实际生产中,I/O密集型,I/O 耗时和CPU耗时的比值这个关键参数是未知且动态变化的,我们需要通过不同场景的压测验证估计。关注CPU、I/O利用率和性能指标(响应时间、吞吐量)的关系

思考题

测试io耗时与cpu耗时工具:apm工具

在4核8线程的处理器使用Runtime.availableProcessors()结果是8,从cpu硬件来看是一个物理核心有两个逻辑核心,但因为缓存、执行资源等存在共享和竞争,所以两个核心并不能并行工作。超线程技术统计性能提升大概是30%左右,并不是100%。现代操作系统层面的调度应该是按逻辑核心数,也就是8来调度的(除非禁用超线程技术)。理论值和经验值只是提供个指导,实际上还是要靠压测。

其他

I/O控制方式——轮询,中断,DMA,通道

0%