Java并发理论基础
一、多线程的便利
更多的处理器核心
更快的响应速度
二、Java多线程并发不安全
指的是多个线程同时访问共享资源时,如果没有正确同步,可能会出现数据错误、程序异常或逻辑混乱的情况
并发不安全的核心问题:共享资源 + 缺乏同步
例如:
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}如果多个线程同时调用increment()方法,预期结果是每次加1,但并发场景下会出现 加了多次结果却没变或者变少的 情况。
这是因为:
count++ 实际上分为三步:
1. 读取 count 的值
2. +1
3. 写回 count多个线程可能都在几乎同时读取到相同的值,执行 +1 后写回,这就会造成“丢失更新”,即所谓的 线程不安全“
如何避免并发不安全
加锁同步:
synchronized、ReentrantLock使用原子类:如
AtomicInteger使用并发容器:
ConcurrentHashMap、CopyOnWriteArrayList
三、Java多线程并发出现问题的根源
1. 可见性问题 —— 线程之间看不到彼此的最新数据
具有可见性:是指一个线程对共享变量的修改,另外一个线程能够立刻看到
现象:
一个线程对共享变量的修改,对另一个线程不可见。
举例:
volatile boolean running = true;
Thread t1 = new Thread(() -> {
while (running) {
// do something
}
});如果没有volatile,另一个线程把running = false;改,但t1可能永远看不到这个修改
根源分析:
Java 的内存模型(JMM)中,每个线程有自己的工作内存(类似缓存),变量修改不会立即同步到主内存
导致一个线程的变更,其他线程看不到
2. 原子性问题 —— 操作是不可分割的
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
现象:
像 i++ 这样的操作在多线程下会出现“丢失更新”问题
举例:
count++; // 实际包含3步操作
1. 读取 count 的值
2. +1
3. 写回 count多个线程同时读写一个变量时,操作被中断或交叉执行,结果就会出错
根源分析:
Java 中的普通变量操作不是原子性的
虽然某些语句只有一行,但背后可能包含多步,如读 => 改 => 写
在没有同步机制时,同时操作同一个共享变量,且操作过程没有正确同步,最终导致彼此的数据读错、写乱、更新丢失
3. 有序性问题 —— 指令可能被重排
有序性:即程序执行的顺序按照代码的先后顺序执行
现象:
变量可能在尚未完成初始化时就被另一个线程访问。
举例:
Room room = new Room();这里的 room 是一个 引用变量,它并不直接存储对象本身,而是存储“指向堆内存中实际对象的地址”。
可以理解成:
room是遥控器(引用)new Room()创建的是电视(对象)
什么叫“先把引用赋值”?
JVM 在执行 room = new Room(); 时,正常流程是:
1. 在堆内存中分配空间
2. 执行构造方法(初始化对象)
3. 将地址赋值给变量 room(即引用)但为了性能优化,JVM 可能会把“步骤3”提前到“步骤2”之前:
1. 分配内存
2. 将地址赋值给变量 room(引用赋值)
3. 执行构造方法(对象还没初始化 )引用变量先获得了堆内存地址(即对象的引用),但这块内存中对应的对象还没有被完全初始化。
根源分析:
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序
四、Java 解决并发问题的方式
synchronized(内置锁)
本质是一种互斥锁机制
保证原子性、可见性、有序性。同一时刻,只能有一个线程进入同步代码块/方法
对象或类作为锁对象,自动加锁解锁
1. 修饰同步方法(锁住当前对象)
代码示例:
public class SyncInstanceMethodDemo {
public synchronized void syncMethod() {
System.out.println(Thread.currentThread().getName() + " 进入实例同步方法");
try { Thread.sleep(500); } catch (InterruptedException ignored) {}
System.out.println(Thread.currentThread().getName() + " 退出实例同步方法");
}
public static void main(String[] args) {
SyncInstanceMethodDemo demo = new SyncInstanceMethodDemo();
new Thread(() -> demo.syncMethod(), "T1").start();
new Thread(() -> demo.syncMethod(), "T2").start();
}
}运行结果:
T1 进入实例同步方法
T1 退出实例同步方法
T2 进入实例同步方法
T2 退出实例同步方法锁对象是当前实例对象(
this)。多个线程调用同一个实例的同步方法时会被串行执行。
可能遇到的问题
public class AccountingSyncBad implements Runnable {
//共享资源(临界资源)
static int i = 0;
// synchronized 同步方法
public synchronized void increase() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String args[]) throws InterruptedException {
// new 两个AccountingSync新实例
Thread t1 = new Thread(new AccountingSyncBad());
Thread t2 = new Thread(new AccountingSyncBad());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("static, i output:" + i);
}
}上述代码创建了两个对象 AccountingSyncBad,然后启动两个不同的线程对共享变量 i 进行操作,操作结果是 1224617 而不是期望的结果 2000000
因为上述代码犯了严重的错误,虽然使用了 synchronized 同步 increase 方法,但却 new 了两个不同的对象,这也就意味着存在着两个不同的对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。
每个对象都有一个对象锁,不同的对象,他们的锁不会互相影响
解决方案:
解决这种问题的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就锁的是当前的类,由于无论创建多少个对象,类永远只有一个,所有在这样的情况下对象锁就是唯一的
2. 修饰同步代码块
某些情况下,我们编写的方法代码量比较多,存在一些比较耗时的操作,而需要同步的代码块只有一小部分,如果直接对整个方法进行同步,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹。
代码示例:
public class AccountingSync2 implements Runnable {
static AccountingSync2 instance = new AccountingSync2(); // 饿汉单例模式
static int i=0;
@Override
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为instance
synchronized(instance){
for(int j=0;j<1000000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}运行结果:
/**
* 输出结果:
* 2000000
*/将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁的对象,当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象的锁,如果当前有其他线程正持有该对象锁,那么新的线程就必须等待,这样就保证了每次只有一个线程执行
i++操作。
当然除了用 instance 作为对象外,我们还可以使用 this 对象(代表当前实例)或者当前类的 Class 对象作为锁,如下代码:
synchronized(this) {
for (int j = 0; j < 1000000; j++) {
i++;
}
}那么 synchronized(this) 实际上和 synchronized(instance) 效果完全相同:
因为 t1 和 t2 运行的都是同一个
Runnable实例 →this是同一个对象。所以它们还是会竞争同一把锁,最终结果依然是 2000000。
但是,t1 和 t2 分别 new 不同的 Runnable 实例 时:
Thread t1 = new Thread(new AccountingSync2());
Thread t2 = new Thread(new AccountingSync2());t1 里的
this是一个对象,t2 里的this是另一个对象。两个线程进入
synchronized(this)时,各自拿到的是不同的锁 → 锁不共享。结果就会出错(小于 2000000)。
3. 修饰静态同步方法
代码示例:
public class StaticSyncMethodDemo {
public static synchronized void staticSyncMethod() {
System.out.println(Thread.currentThread().getName() + " 进入静态同步方法");
try { Thread.sleep(500); } catch (InterruptedException ignored) {}
System.out.println(Thread.currentThread().getName() + " 退出静态同步方法");
}
public static void main(String[] args) {
new Thread(() -> StaticSyncMethodDemo.staticSyncMethod(), "T5").start();
new Thread(() -> StaticSyncMethodDemo.staticSyncMethod(), "T6").start();
}
}运行结果:
T5 进入静态同步方法
T5 退出静态同步方法
T6 进入静态同步方法
T6 退出静态同步方法说明:
静态同步方法锁住类的 Class对象,两个线程顺序执行。
静态同步方法锁的是“类级别的锁”,也就是 JVM 为当前类创建的唯一
Class对象,如Example.class,不论你 new 多少个对象,这个锁都是一样的
4. 原理分析
4.1 加锁释放锁原理
同步块的实现使用了monitorenter和monitorexit指令
同步方法则是依靠方法修饰符上的ACC_SYNCHRONIZED来完成
本质是对一个对象的监视器进行获取,而这个过程是排他的,也就是同一时刻只能有一个线程获取到由synchronized所保护对象的监视器。
注:synchronized 是非公平锁

4.2 可重入的实现原理
volatile 关键字
保证可见性、禁止指令重排序
不保证原子性(适用于状态标志)

可见性的实现
代码示例:
import java.util.concurrent.TimeUnit;
public class VolatileExample1 {
/**
* volatile写-读的内存语义:
* 1. 当写一个volatile变量时,JMM会把该线程对应本地内存中的共享变量值刷新到主内存
* 2. 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主存中读取共享变量
* 1.stop变量增加volatile关键字之后,主线程修改stop=true,此时并不会只修改本地内存。JMM还会触发本地
* 内存到主内存的回写,所以主内存中stop就会变成true。
* 2.当线程A尝试读取volatile关键字下的stop变量的时候,就不会从再从本地内存读了,转而去读取主内存中的
* stop值,此时读取到的stop =true。两个线程一致了。
*/
// 加 volatile,保证可见性
// 如果一个字段被声明为volatile,Java内存模型(JMM)确保所有线程看到这个变量的值是一样的
private static volatile boolean stop = false;
public static void main(String[] args) {
// Thread-A
new Thread(() -> {
while (!stop) {
// 模拟一些工作
}
System.out.println("3: " + Thread.currentThread().getName() + " 停止了");
}, "Thread A").start();
// Thread-main
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("1: 主线程等待一秒...");
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("2: 将 stop 变量设置为 true");
stop = true;
}
}运行结果:
1: 主线程等待一秒...
2: 将 stop 变量设置为 true
3: Thread A 停止了有序性实现
代码示例:
public class VolatileOrderExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // 写共享变量a
flag = true; // 写 volatile变量
}
public void reader() {
if (flag) { // 读 volatile变量
System.out.println(a); // 有序性保证下,这里一定会输出1
}
}
public static void main(String[] args) {
VolatileOrderExample example = new VolatileOrderExample();
Thread t1 = new Thread(example::writer);
Thread t2 = new Thread(example::reader);
t1.start();
t2.start();
}
}有序性体现在哪里?
volatile 关键字修饰共享变量会禁止重排序,执行到 volatile 变量,其前面的所有语句必须执行完毕,其后面语句都未执行
当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:
写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。
换句话说:
当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
a = 1在flag = true之前执行;flag = true是volatile写;在另一个线程中读取
flag(volatile读)为 true 后,根据 happens-before 原则,a = 1 的写入对该线程可见。
如果没有 volatile:
可能会出现 flag == true,但 a == 0 的情况(由于指令重排序或可见性问题)。
final 关键字(不可变性)
final 是 Java 的一个修饰符,用于 修饰类、方法和变量,表示不可更改的含义:
修饰类(final class)
作用:
被 final 修饰的类不能被继承。
示例:
final class Animal {
public void run() {
System.out.println("Animal is running.");
}
}
// 错误示例:
// class Dog extends Animal {} // 报错:不能继承 final 类修饰方法(final method)
作用:
被 final 修饰的方法不能被子类重写(Override)。
示例:
class Parent {
public final void sayHello() {
System.out.println("Hello from parent.");
}
}
class Child extends Parent {
// 报错:不能重写 final 方法
// public void sayHello() {}
}修饰变量
作用:
变量值一旦赋值之后不能再更改,即表示常量。
分类:
示例:
public class Demo {
final int age = 18;
static final String SCHOOL_NAME = "OpenAI Academy";
public void test() {
final int x = 100;
// x = 200; // 报错:不能再赋值
}
}注意:
final 成员变量 必须在定义时或构造函数中初始化;
final 引用类型变量:引用不能变,但对象内容可以变。
final List<String> list = new ArrayList<>();
list.add("hello"); // 合法
// list = new ArrayList<>(); // 报错五、Java内存模型(JMM)
定义
Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入时,能对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

