Java并发总论

什么是并发?并发和并行的区别?为什么需要并发?

两个任务确实在同时运行,就是并行;两个任务不是同时运行,而是每个任务执行一小段,就是并发。

并行一定要有多个核支持,并发模式在单核cpu上就可以完成。

串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。


更多处理器核心:线程是一个处理器的最小调度单元,而一个线程同一时间也只能运行在一个处理器核心上。如果是单线程,同一时间只会有一个处理核心被占用,其他的都是浪费掉的,处理器有再多的核心也没办法提升程序执行效率。相反如果线程使用多线程技术,那并行的执行这些线程就可以充分利用多核优势,让程序执行更快。

更快响应速度:对于一致性要求不高的一系列操作,例如生成订单快照,发送邮件等,这些操作完全可以单独放入一个线程,异步执行。这样就只需要完成”插入订单数据”,“记录订单货品销售数量”完成之后就告诉用户订购成功,缩短用户响应时间,体验会更好。

多线程并发的问题在哪里?

如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。以下代码演示了 500 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 500。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.demo;/*
* @author 0kr
* @version 1.0
*/
public class ThreadRaceConditionDemo {
// 共享变量
private static int cnt = 0;

public static void main(String[] args) throws InterruptedException {
// 创建线程数组
Thread[] threads = new Thread[500];

// 创建并启动500个线程
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
// 使竞态条件更容易出现:读取-修改-写入操作分解
int temp = cnt; // 读取
// 模拟线程切换的可能性,强制CPU可能切换到其他线程
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
temp = temp + 1; // 修改
cnt = temp; // 写入
}
});
threads[i].start();
}

// 等待所有线程执行完毕
for (Thread t : threads) {
t.join();
}

// 输出最终结果
System.out.println("预期结果: 500");
System.out.println("实际结果: " + cnt);
}
}

为什么会出现并发问题?

出现以上问题是由于三个原因:可见性、原子性、有序性。

可见性是说,线程A修改好的数据没有被B读取,B反而读了旧值,导致A的操作被覆盖。这是由于线程本地内存和Java主内存没有及时同步数据导致的(该部分详见JMM);

原子性是说一个操作或多个操作要么全部执行且不会被任何因素打断,要么就都不执行。举个例子,比如说:

1
i+=1

该程序需要三条 CPU 指令:

1.将变量i从内存读取到 CPU寄存器;

2.在CPU寄存器中执行i+1操作;

3.将最后的结果i写入内存。

由于CPU分时复用(线程切换)的存在,线程1执行了”第一条指令”后,就切换到线程2执行,假如线程2执行了这三条指令后,再切换回线程1执行后续两条指令,将造成最后写到内存中的i值是2而不是3。

有序性,是指在程序执行时,为了提高性能,编译器和处理器常常会对指令做重排序。主要有三种:编译器优化的重排序,指令级并行重排序,内存系统重排序。

重排序本来是会提升程序性能,我们应该支持,另外一方面有的重排序会导致程序出问题,我们应该禁止。 那到底哪些应该禁止,哪些应该支持呢?这个事情如果让]ava程序员自己来分析,明显是很困难的。所以这个工作就由JMM承担了。

并发问题如何解决?

JMM可以理解为,Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括:
volatile、synchronized 和 final 三个关键字
Happens-Before 规则

下面来依次讲解:

JMM(Java内存模型)

通常线程之间交换信息的方式有共享内存消息传递两种实现方式。共享内存通信指线程A和B有共享的公共数据区,线程A写数据,线程B读数据,这样就完成了一次隐式通信。 而消息传递通信是指线程之间没有公共数据,需要线程间显式的直接发送消息来进行通信。

Java主要采用的是第一种共享内存的方式,所以线程之间的通信对于开发人员来说都是隐式的,如果不理解这套工作机制,可能会碰到各种奇奇怪怪的内存可见性问题。

同步是指一种用来控制不同的线程之间操作发生相对顺序的机制。同步需要程序员显式的定义,主要是指定一个方法或者一段代码需要在线程之间互斥执行。(Java提供了很多用来做同步的工具,比如Synchronized,Lock等)


下面的话醍醐灌顶:

在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享,共享变量才会有内存可见性问题,所以会受到内存模型的影响。而局部变量,方法定义参数和异常处理器参数不会在线程之间共享,不会有类似问题(即:线程间贡献堆内存,每个线程维护自己独立的栈空间)。

Java 线程之间的通信由 Java 内存模型(本文简称为 JMM,可以理解为一套规则)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了高速缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。(如果仍有疑问可以看附图)Java 内存模型的抽象示意图如下:

从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经历下面 2个步骤:

首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。

然后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量。

从整体来看,这两个步骤实质上是线程 A 在向线程 B 发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交豆,来为 java 程序员提供内存可见性保证。

Happen-Before

由于我们需要规定哪一些重排序是可以优化的,哪些是必须禁止的,因此jmm定义了happen-before规则,这套规则一方面给程序员的承诺,一方面是对编译器和处理器的约束。JMM承诺程序员基于这套规则编程,即便不理解重排序,程序也不会因为发生了重排序出问题,也不会出现内存可见性问题。而另外一方面,Java平台在具体实现的时候,有了这套规则的也就知道了禁止重排序应该禁止到什么程度,比如有些重排序并不会打破这套规则,也并不会改变程序的执行结果,那就应该支持。

规则一共八条:

Synchronized

三种实现方式

修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BankAccount {
private int balance = 0;

// synchronized 修饰实例方法,锁是当前实例对象 this
public synchronized void deposit(int amount) {
int newBalance = balance + amount;
try {
Thread.sleep(10); // 模拟网络延迟
} catch (InterruptedException e) {
e.printStackTrace();
}
balance = newBalance;
System.out.println(Thread.currentThread().getName() + " 存款后余额: " + balance);
}

public int getBalance() {
return balance;
}
}

修饰静态方法:也就是给当前类加锁,因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态synchronized 方法占用的锁是当前实例对象锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class CounterService {
private static int count = 0;

// synchronized 修饰静态方法,锁是 CounterService.class 对象
public static synchronized void increment() {
count++;
try {
Thread.sleep(50); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("当前计数: " + count);
}

// 另一个静态同步方法,使用的是同一把锁
public static synchronized int getCount() {
return count;
}
}

修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private List<String> processedFiles = new ArrayList<>();    
public void processFile(String fileName) {
// 其他非同步操作
readFile(fileName);

// 使用同步代码块保护关键区域,指定锁对象为 fileLock
synchronized (fileLock) {
if (!processedFiles.contains(fileName)) {
processedFiles.add(fileName);
System.out.println("处理文件: " + fileName);
// 更新文件处理状态等关键操作
}
}

// 继续其他非同步操作
generateReport(fileName);
}

总结:

可重入原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

这样做的好处是:

1.递归安全性:很多时候,递归函数需要访问或修改共享资源这要求递归调用的每个级别都能获取相同的锁。如果没有可重入锁,这样的递归函数很容易导致死锁。

2.简化锁管理:由于不需要担心在同一个线程中多次获取或释放同一个锁,开发者可以更自由地编写和组合同步代码块,而不用担心由于误用锁而导致的问题或避免意外死锁。

底层实现原理

Java中对于同步块的实现使用了monitorenter和monitorexit指令,而同步方法则是依靠方法修饰符上的ACC_SYNCHRONIZED来完成的。无论采用哪种方式,其本质是对一 个对象的监视器(monitor进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。下面通过一个图来展示线程,对象0bject,监视器Monitor,同步队列SychronizedQueue之间的关系:

总结:

Synchronized的语义底层是通过一个monitor(监视器锁)的对象来完成,每个对象有一个监视器锁(monitor)。每个Synchronized修饰过的代码当它的monitor被占用时就会处于锁定状态,当同步代码执行完毕,释放monitor后其他线程重新尝试获取monitor的所有权,过程:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1。
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

(多线程中)锁升级原理

Volatile

volatile关键字的作用:如果一个字段被声明成volatile,JMM就会确保所有线程看到这个变量的值是一样的。

其实说白了问题就是本地内存没有及时更新到主内存导致的。因为本地缓存是抽象结构,包括高速缓存,写缓冲区等,因此volatile的实现就是基于对这些组件的操作来的。

volatile写-读的内存语义:

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

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

简单来说,使用volatile会使得本地内存失效。

有序性实现

禁止重排序规则

为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。针对会改变语义的场景,Java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序,JMM 针对编译器制定的 volatile 重排序规则表如下(NO就是需要禁止重排序的场景)

其他的都好理解,volatile写为什么不能重排序到普通读/写之前?因为根据JMM的happens-before规则,程序顺序中在volatile写之前的所有操作结果必须对其他线程可见,才能保证其他线程在读取该volatile变量时可以看到这些操作的结果。

在volatile写操作之前,JVM会插入一个”存储-存储屏障”(StoreStore Barrier),在volatile写操作之后,会插入一个”存储-加载屏障”(StoreLoad Barrier)。这些屏障的作用是强制刷新缓存和阻止重排序。

总结

volatie 关键字主要用于解决并发安全问题(原子性、可见性、有序性),它常用于多线程环境下的单次操作(单次读或单次写)。

(1)对于可见性的保证,是基于 volatile 的内存语义来确保;

(2)对于有序性的保证,通过禁止指令重排进行控制;

(3)对于原子性的保证,

​ volatile 无法确保单个变量读写的原子性问题,但可以通过搭配其他技术来达到保证原子性的目的,例如:

​ -3.1 JMM 内存模型规定 volatile long 和 volatile double 具有原子性(这点是底层 JVM 保证的)

​ -3.2 volatile 可以通过和 CAS 结合保证原子性(可以参考 JUC 包下的类,AtomicInteger)

Final

修饰类

当某个类的整体定义为final时,就表明了你不能打算继承该类,而且也不允许别人这么做。即这个类是不能有子类的。

修饰方法

修饰参数

如果有不希望改变参数的场景,可以用final修饰:

修饰变量

final重排序禁止规则

很简单,就是final域一定要在构造方法结束前完成写入。这是为了保证当对象暴露在线程中时,final已经被初始化。

final读也是因为这个理由:读final域之前,必须读过该对象的引用。

——————————————————————END————————————————————————

附图: