Java并发的问题及应对办法

简介: 并发问题的源头并发?为啥需要并发呢?自然是为了性能,增强算力以及协调能力在现今计算机器体系中,涉及性能的主要有CPU、内存、IO三方面,而这三者的速度也是天壤之别,形象之讲,CPU天上一天,内存是地上一年,IO则要地上十年

并发问题的源头

并发?为啥需要并发呢?自然是为了性能,增强算力以及协调能力

在现今计算机器体系中,涉及性能的主要有CPU、内存、IO三方面,而这三者的速度也是天壤之别,形象之讲,CPU天上一天,内存是地上一年,IO则要地上十年

怎么应对:

1、CPU增加了多级缓存,均衡与内存的速度差异,并且还从单核发展为多核增加算力

2、操作系统增加线程,分时复用CPU,均衡CPU与IO的速度差异

3、通过即时编译器重排序,处理器乱序执行,以及内存系统重排序优化指令执行次序,更好地利用缓存

但这些措施并不是百利无害的,并发问题就是其中一害。

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

多核时代,每个核都有各自的L1,L2缓存,在各自缓存中修改的数据相互不可见。

image.png

《缓存是个面子工程》[1]提到的硬件缓存,也带来了并发问题。

image.png

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

这主要有些看似一行的代码,其实需要多条CPU指令才能完成

如count+=1,需要三条指令

指令1:把变量count从内存加载到CPU的寄存器

指令2:在寄存器中执行+1操作

指令3:最后将结果写入内存

当多线程时,线程切换时三条指令就会被错误执行,打破了原子性,导致逻辑的错误。

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

编译器为了优化性能,有时改变了程序中语句的先后顺序。

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

new Singleton()这句话感觉是

1.分配一块内存M2.在内存M上初始化Singleton对象3.然后M的地址赋值给instance变量

实际执行路径却是:

1.分配一块内存M2.将M的地址赋值给instance变量3.最后在内存M上初始化Singleton对象

JMM

如何解决上述的三大问题,JSR-133定义了内存模型JMM

A memory model describes, given a program and an execution trace of that program, whether the execution trace is a legal execution of the program. For the Java programming language, the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

也就是说一个内存模型描述了一个给定的程序和和它的执行路径是否一个合法的执行路径。对于java序言来说,内存模型通过考察在程序执行路径中每一个读操作,根据特定的规则,检查写操作对应的读操作是否能是有效的。java内存模型只是定义了一个规范,具体的实现可以是根据实际情况自由实现的。但是实现要满足java内存模型定义的规范。

内存模型的种类大致有两种:

Sequential Consistency Memory Model: 连续一致性模型。这个模型定义了程序执行的顺序和代码执行的顺序是一致的。也就是说 如果两个线程,一个线程T1对共享变量A进行写操作,另外一个线程T2对A进行读操作。如果线程T1在时间上先于T2执行,那么T2就可以看见T1修改之后的值。这个内存模型比较简单,也比较直观,比较符合现实世界的逻辑。但是这个模型定义比较严格,在多处理器并发执行程序的时候,会严重的影响程序的性能。因为每次对共享变量的修改都要立刻同步会主内存,不能把变量保存到处理器寄存器里面或者处理器缓存里面。导致频繁的读写内存影响性能。

这种模型相当于禁用了缓存。如果再禁止编译器优化,就算是彻底解决上述问题了,但性能将受到严重影响。

Happens-Before Memory Model: 先行发生模型。这个模型理解起来就比较困难。先介绍一个先行发生关系 (Happens-Before Relationship)   如果有两个操作A和B存在A Happens-Before B,那么操作A对变量的修改对操作B来说是可见的这个先行并不是代码执行时间上的先后关系,而是保证执行结果是顺序的

happens-before

happens-before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 happens-before 规则。

happens-before规则:

程序次序规则(program order rule): 在一个线程内,先在前面的代码操作先行。准确的说控制流顺序而不是代码顺序。需要考虑分支,循环等结构。

管程锁定规则(monitor lock rule):同一个资源锁,先unlock,之后才能lock。

Volatile变量规则(volatile variable rule):一个变量被volatile修饰,多线程操作,先执行操作,再执行读操作。(同时写操作只能有一个)

线程启动规则(Thread start rule):Thread对象的start方法,先行发生于此线程的每一个方法。

线程终止规则(Thread Termination rule):该线程的所有方法,先行发生于该线程的终止检测方法。例如:可以通过Thread.join方法结束,Thread.isAlive()的返回值等手段检测到线程已经终止执行。

线程中断规则(Thread Interruption Rule): 中断方法先行发生于,中断检测方法。中断方法interrupt(),中断检测interrupted()方法。

对象终结规则(finalizer rule): 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalizer方法的开始。

传递性(Transitivity): 如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

as-if-serial

在谈happens-befre常会提到as-if-serial

即时编译器保证程序能够遵守as-if-serial属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。也就是经过重排序的执行结果要与顺序执行的结果保持一致。

而且,如果两个操作之间存在数据依赖时,编译器不能调整它们的顺序,否则将造成程序语义的改变。

public class AsIfSerialDemo {
    public static void main(String[] args) {
        int a = 10;//1
        int b = 10;//2
        int c = a+ b;//3
    }
}

上面示例中:1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。

VS 时间先行

对于happens-before先行发生,怎么理解,最常与“时间先后发生”搞混淆。

happens-before 关系是用来描述两个操作的内存可见性的。

如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。那么与“时间先后发生”顺序有什么区别?

《JSR-133: JavaTM Memory Model and Thread Specification》[2],happens-before是这样定义的:

Two actions can be ordered by a happens-before relationship. If one action happens-before another, then the first is visible to and ordered before the second. It should be stressed that a happens-before relationship between two actions does not imply that those actions must occur in that order in a Java platform implementation. The happens-before relation mostly stresses orderings between two actions that conflict with each other, and defines when data races take place.

从定义中可以看出两点:

1、the first is visible to and ordered before the second

如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前

2、does not imply that those actions must occur in that order in a Java platform implementation

两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行


由这两条可以得出,JMM是要求当有happens-before关系时,不仅要求了可见性,而且在时间上也得保证有序。然而在不改变语义的前提下,Java平台的实现可以自主决定。这也就表明了happens-before与时间先后没有更大的关联性。

A happens-before B does not imply A happening before B.

A happening before B does not imply A happens-before B.

一个操作 “先行发生” 并不意味着这个操作必定是“时间上的先发生”


// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

根据happens-before规则第一条,“int i = 1” 的操作先行发生(Happens-before)于 “int j = 2”,但在保证语义不改变的前提下,重排序了两条语句,那在时间上,“int j=2”先执行了。

《The Happens-Before Relation》[3]这篇文章中,作者还举了个示例:


int A = 0;
int B = 0;
void foo()
{
    A = B + 1;              // (1)
    B = 1;                  // (2)
}

虽然(1) happens-before (2),而且从上面的as-if-serial判断,(1) 得happen before (2) ,但作者观察并不是。

image.gifimage.png

从图上可看出,A被赋值为0,B被赋值为1,但 (1) 没被执行呢。

关于这个问题,在stackoverflow happens-before [4] 被讨论了。有人指出作者说得不对,而也有人给出解答:

A and B are locations in memory. However the operation B+1 does not happen in memory, it happens in the CPU. Specifically, the author is describing these two operations.

A = B + 1 (1)

A1 - The value in memory location B (0) is loaded into a CPU registerA2 - The CPU register is incremented by 1A3 - The value in the CPU register (1) is written to memory location A

B = 1 (2)

B1 - The value 1 is written to memory location B

Happens-Before requires that the read of B (step A1) happens before the write of B (step B1). However, the rest of the operations have no interdependence and can be reordered without affecting the result. Any of these sequences will produce the same outcome

A1, B1, A2, A3A1, A2, B1, A3A1, A2, A3, B1

一个操作 “时间上的先发生” 也不能代表这个操作会是“先行发生”

private int value = 0;
// 线程 A 调用
pubilc void setValue(int value){    
    this.value = value;
}
// 线程 B 调用
public int getValue(){
    return value;
}

假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue() ,那么线程 B 收到的返回值是什么? 0和1都有可能。因为两个操作之间没有happens-before关系。

volatile

volatile字段的happens-before关系指的是在两个不同线程中,【volatile的写操作】 happens-before之后 【对同一字段的读操作】。这里有个关键字“之后”,指的是时间上的先后。也就是我这边写,你之后再读就一定能读得到我刚刚写的值。普通字段则没有这个保证。也就是上面的setValue()与getValue()示例问题


int a=0;
volatile int b=0;
public void method1() {
  int r2 = a;
  b = 1;
}
public void method2() {
  int r1 = b;
  a = 2;
}

首先,b加了volatile之后,并不能保证b=1一定先于r1=b,而是保证r1=b始终能够看到b的最新值。比如说b=1;b=2,之后在另一个CPU上执行r1=b,那么r1会被赋值为2。如果先执行r1=b,然后在另外一个CPU上执行b=1和b=2,那么r1将看到b=1之前的值。

在没有标记volatile的时候,同一线程中,r2=a和b=1存在happens before关系,但因为没有数据依赖可以重排列。一旦标记了volatile,即时编译器和CPU需要考虑到多线程happens-before关系,因此不能自由地重排序。

volatile与synchronized的区别,可以查看《volatile synchronized cas》[5]

总结

本篇总结了Java并发问题的本质:可见性、原子性、有序性;以及应对这些问题,JMM中happens-before模型的规则。以及happens-before与happen before的区别。

References

[1] 《缓存是个面子工程》: https://www.zhuxingsheng.com/blog/caching-is-a-face-project.html

[2] 《JSR-133: JavaTM Memory Model and Thread Specification》: https://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf

[3] 《The Happens-Before Relation》: https://preshing.com/20130702/the-happens-before-relation/

[4] stackoverflow happens-before : https://stackoverflow.com/questions/53264829/i-know-that-happens-before-does-not-imply-happening-before-can-the-code-a-b

[5] 《volatile synchronized cas》: https://www.zhuxingsheng.com/blog/volatile-synchronized--cas.html

目录
相关文章
|
21天前
|
安全 Java Go
Java vs. Go:并发之争
【4月更文挑战第20天】
31 1
|
21天前
|
数据采集 存储 Java
高德地图爬虫实践:Java多线程并发处理策略
高德地图爬虫实践:Java多线程并发处理策略
|
4天前
|
缓存 安全 Java
【Java面试——并发基础、并发关键字】
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。 乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是: 比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
|
5天前
|
缓存 安全 Java
Java的线程池与并发工具类技术性文章
Java的线程池与并发工具类技术性文章
10 0
|
5天前
|
监控 Java 编译器
Java的内存模型与并发控制技术性文章
Java的内存模型与并发控制技术性文章
14 2
|
5天前
|
安全 Java 程序员
Java的迭代器与并发集合的技术性文章
Java的迭代器与并发集合的技术性文章
5 0
|
10天前
|
安全 Java 开发者
探索Java中的多线程编程与并发控制
多线程编程是Java编程中不可或缺的一部分,它允许程序同时执行多个任务,从而显著提高程序的整体性能。然而,多线程编程也带来了诸如数据不一致、死锁等并发问题。本文将深入探讨Java中的多线程编程技术,包括线程的创建、同步与通信,并介绍几种常用的并发控制策略,旨在帮助读者更好地理解并发编程的复杂性和挑战,并学会如何编写高效、安全的并发程序。
|
12天前
|
缓存 安全 Java
JAVA多线程编程与并发控制
```markdown Java多线程编程与并发控制关键点:1) 通过Thread或Runnable创建线程,管理线程状态;2) 使用synchronized关键字和ReentrantLock实现线程同步,防止数据竞争;3) 利用线程池(如Executors)优化资源管理,提高系统效率。并发控制需注意线程安全,避免死锁,确保程序正确稳定。 ```
|
18天前
|
算法 Java 程序员
Java中的线程同步与并发控制
【5月更文挑战第18天】随着计算机技术的不断发展,多核处理器的普及使得多线程编程成为提高程序性能的关键。在Java中,线程是实现并发的一种重要手段。然而,线程的并发执行可能导致数据不一致、死锁等问题。本文将深入探讨Java中线程同步的方法和技巧,以及如何避免常见的并发问题,从而提高程序的性能和稳定性。
|
18天前
|
安全 Java 容器
Java一分钟之-并发编程:并发容器(ConcurrentHashMap, CopyOnWriteArrayList)
【5月更文挑战第18天】本文探讨了Java并发编程中的`ConcurrentHashMap`和`CopyOnWriteArrayList`,两者为多线程数据共享提供高效、线程安全的解决方案。`ConcurrentHashMap`采用分段锁策略,而`CopyOnWriteArrayList`适合读多写少的场景。注意,`ConcurrentHashMap`的`forEach`需避免手动同步,且并发修改时可能导致`ConcurrentModificationException`。`CopyOnWriteArrayList`在写操作时会复制数组。理解和正确使用这些特性是优化并发性能的关键。
24 1