Java并发编程(一)—Java内存模型以及线程安全

目录

一、Java内存模型

JMM的核心概念

二、什么是线程安全? 

1、原子性

2、有序性

3、可见性

三、如何确保线程安全?

1、sychronized关键字

2、Lock接口和其实现

3、volatile关键字

4、Atomic原子类

5、ThreadLocal

6、不可变对象

7、并发集合类

8、并发工具类

9、Future和Callable


一、Java内存模型

在谈及线程安全前,需要了解内存模型一下

Java内存模型(Java Memory Model,JMM)是一种抽象的概念,JMM并不真实存在,它是一种规范规定了在Java并发编程中如何处理多线程之间的内存交互程序中变量在内存中的访问方式

5eb5081162df4468bfecc70ea194214c.jpeg

JMM的核心概念

1、主内存与工作内存:

主内存:是所有线程共享的内存区域,所有变量都存储在主内存中,主内存是共享的

工作内存每个线程都有自己的工作内存(局部变量存储区),线程对共享变量的所有操作都发生在工作内存中,然后同步回主内存

这种模型允许线程在本地缓存共享变量的副本,提高性能,但也带来了同步的复杂性

2、内存屏障

内存屏障(Memory Barrier)是JMM中用于控制内存访问顺序的指令。它确保指令序列中的内存读写操作按照特定的顺序执行,从而保证线程间的内存可见性和有序性

3、Happens-Before规则

Happens-Before是JMM中最核心的概念之一,它定义了一组偏序关系,用于判断两个操作之间的内存可见性和有序性,用于描述多线程程序中操作的执行顺序,确保了线程之间的正确通信和数据一致性

简而言之:如果在同一个线程中,操作A在操作B之前执行,那么我们说一个操作A happens-before 另一个操作B,那么A的执行结果对B是可见的,且A的执行顺序排在B之前,也就是先执行的操作的结果必须对后执行的操作可见

用一个例子来深入了解内存模型

🌰:线程A和线程B从主内存读取和修改x=1的过程

419f7d591bdf461fa9f107e3838ff3e4.png

线程A和线程B要进行通信的话,必须要进行以下几个步骤:

  1. 初始化:x = 1,存储在主内存。

  2. 线程A读取:A从主内存读取x,复制值1到A的工作内存。

  3. 线程B读取:B从主内存读取x,复制值1到B的工作内存。

  4. 线程A修改:A在工作内存中修改x至2,此时只有A的工作内存中的x值被修改,主内存中的x值仍然是1

  5. 线程B重新读取:B从主内存读取x的值。如果在此之前线程A还没有将其工作内存中修改后的x值写回到主内存,那么B读取的将是旧值1,而不是A修改后的值2

因此可见:线程A对变量的修改如果不会立即对线程B可见,就会引起线程安全问题

二、什么是线程安全? 

线程安全:在多线程环境下,多个线程可以安全地访问和操作共享数据,而不会引发数据不一致或程序错误的问题

JMM 解决并发程序中最关键的两个问题:线程间的可见性指令重排序

  • 线程间的可见性:确保当一个线程修改了共享变量的值时,其他线程可以立即看到这一改变。没有良好的可见性保证,一个线程对共享变量的修改可能对其他线程不可见,导致数据不一致。
  • 指令重排序:为了提高性能,编译器处理器常常会改变指令的执行顺序(只要这种改变不影响单线程内的程序逻辑)。然而,在多线程环境下,这种重排序可能导致严重问题

指令重排序(Instruction Reorder):一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的


🌰例如以下这段代码,JVM在真正执行时,可能会发生指令重排序

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a * a;     //语句4

虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,因为处理器在进行重排序时是会考虑指令之间的数据依赖性

在这个例子中,语句3和语句4依赖于语句1和语句2的结果,而且语句4还依赖于语句3的结果。因此,理论上,编译器和处理器不能将语句4重排序到语句3之前,也不能将语句3重排序到语句1之前。

如果在一个单线程环境中运行,指令重排序不会改变程序的逻辑结果,因为所有的操作都是顺序的,并且遵循数据依赖性

但在多线程环境下,如果没有适当的同步机制,指令重排序可能会导致不同线程看到的数据状态不一致,从而产生竞态条件

例如,如果另一个线程正在读取 a 或 r 的值,而当前线程执行了指令重排序,可能会导致另一个线程看到的是尚未更新的值,或者在某些情况下,看到的是不一致的状态

因此:需要使用适当的同步机制来防止指令重排序导致的不一致状态


实际上,线程安全问题的具体表现体现在三个方面,原子性有序性可见性,JMM 通过以下几种方式来控制和协调上述现象:

1、原子性

原子性:一个操作要么全部完成,要么全部不完成,不会被中断

JMM保证了基本类型的读取和写入操作是原子的,即不可分割的。但是,复合操作(如i++)需要额外的同步机制(Automic类)才能保证原子性

也就是:不可变对象、synchronizedLockAtomic原子类

例如:两个线程同时尝试修改同一个变量,原子性可以确保这个修改操作作为一个整体被执行,而不是被另一个线程业务

场景:银行取钱,你想要从账户中取出100元。在理想情况下,这个操作应该是“原子”的,也就是说,要么整个取款操作成功完成(你拿到了100元,账户余额减少了100元),要么操作完全不发生(你没拿到钱,账户余额不变)。中间不能出现任何状态,比如你只拿到了50元而账户扣除了100元,这会导致数据不一致

2、有序性

有序性保证了指令的执行顺序不会导致数据依赖关系的破坏

JMM限制了编译器和处理器对指令的重排序,以避免破坏程序的语义。volatilesynchronizedfinal等关键字提供了内存屏障,强制执行特定的内存操作顺序,从而保证了线程之间的正确交互

也就是:happens-before原则、volatilesynchronizedfinal

例如:如果一个线程先读取变量A,然后读取变量B,有序性确保在另一个线程中,如果先修改了B,然后再修改A,那么第一个线程读取B时,不会看到A的更新值,因为它应该先读取A的旧

场景:排队买票,如果你前面的人买完票后,后面的人突然插队到你前面买了票,这就打破了正常的顺序

3、可见性

可见性当一个线程修改了一个共享变量后,其他线程是否能够看到这个修改

JMM规定,当一个线程修改了共享变量的值,新值对其他线程来说是可见的。通常通过volatile关键字、synchronized代码块或方法、以及final字段来实现

也就是:volatilesynchronizedLockfinal

场景:五子棋游戏,你在游戏中放置了黑棋,然后告诉朋友“我已经在某个位置下了黑棋”。为了游戏能够正常进行,你的朋友必须能看到你刚下的黑棋的位置,才能做出相应的策略

这三个概念对于多线程编程非常关键,它们确保了程序在并发执行时的正确性和一致性。通过使用、volatile关键字、原子变量等机制,程序员可以控制和管理多线程环境下的原子性、可见性和有序性,从而避免数据竞争、死锁等问题,使程序能够正确地运行

三、如何确保线程安全?

这里大概叙述一下,后续文章进行详细补充,具体可以采取以下几种策略:

1、sychronized关键字

        确保同一时间只有一个线程可以访问资源。

public synchronized void method() {
    // 访问和修改共享资源
}

2、Lock接口和其实现

使用java.util.concurrent.locks包中的ReentrantLock等类允许更细粒度的锁控制,如公平锁、非公平锁、读写锁等

private final ReentrantLock lock = new ReentrantLock();

public void method() {
    lock.lock();
    try {
        // 访问和修改共享资源
    } finally {
        lock.unlock();
    }
}

3、volatile关键字

用于变量,确保变量的读写操作具有可见性,即一个线程对变量的修改对其他线程立即可见。volatile不保证原子性,但对于简单的读写操作(如对基本类型的读写)

4、Atomic原子类

使用java.util.concurrent.atomic包中的原子变量类,如AtomicIntegerAtomicReference等,这些类通过底层的硬件支持实现高效的原子操作。

private AtomicInteger count = new AtomicInteger(0);

public void increment() {
    count.incrementAndGet();
}

5、ThreadLocal

ThreadLocal变量为每个线程提供独立的副本,避免了线程间的共享和同步问题

6、不可变对象

设计不可变对象(immutable objects),即对象在创建后其状态不可更改,这样可以避免多线程访问时的同步问题。例如:String、Integer等包装类都是不可变的

public final class ImmutableClass {
    private final int value;

    public ImmutableClass(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}

7、并发集合类

使用java.util.concurrent包中的线程安全集合类,如ConcurrentHashMapCopyOnWriteArrayList等,这些类已经内建了线程安全机制。

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", 1);

8、并发工具类

CountDownLatchCyclicBarrierSemaphore等,提供了更复杂的线程同步手段

9、FutureCallable

可以用于异步执行任务,内部使用线程池和同步机制来保证线程安全

通过这些机制,Java可以在多线程环境中有效地管理资源访问,保持数据的一致性和正确性

其中

  • Volatile 实现可见性、有序性

当一个线程修改了一个volatile变量的值,这个修改会立即被其他线程可见,无需等待主内存和工作内存之间的数据同步

  • CAS 实现原子性

Java中的CAS(Compare and Swap,比较并交换)是一种无锁技术,当多个线程同时尝试对同一个共享变量执行CAS操作时,只有一个线程会比较成功并更新变量的值,其他线程则会因为比较失败而需要重新尝试,相对同步机制,性能会有一定的优化

  • Lock 实现原子性、可见性、有序性

Java中接口定义了锁的操作接口,它的实现类,如ReentrantLock,内部持有一个AQS对象,AQS通过内部维护一个同步状态,以及一个FIFO队列来管理获取锁的线程。而这个管理线程的过程就会涉及到CAS和volatile的的操作

  • Synchronized 实现原子性、可见性、有序性

同一时间只有一个线程可以执行synchronized的代码,意味着这个线程在执行时不会被干扰,其他线程也会按照预定的顺序执行同步代码,这样就实现了原子性和有序性。

而在该线程执行期间,它会直接在主内存中读取操作共享变量,当下个线程执行代码块时,也就会看到最新的数据,从而实现了可见性

下一篇:Java并发编程(二)—volatile关键字的作用及使用场景-CSDN博客

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值