java并发基础(1) - 理论基础

通过了解并发的理论基础和编程基础,让我们对并发有一个总体的认识,本文先了解下并发的理论基础。

1. 并发的来源

CPU、内存、I/O 设备的速度从前到后存在明显的速度差异。为了合理利用 CPU 的高性能,计算机做了以下事情:

  • CPU 增加了缓存,均衡与内存的速度差异;
  • 操作系统增加了进程、线程,分时复用 CPU,来均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,优化缓存使用。
     

2. 并发不安全的本质:可见性、原子性和有序性

并发虽然提高了计算机的执行效率,但是并发会引发:缓存导致的可见性问题,线程切换带来的原子性问题,编译优化带来的有序性问题。

2.1.(CPU缓存导致的)可见性问题

一个线程对共享变量的修改,另外一个线程能够立刻看到,我们称为可见性。

看一个案例:两个线程同时对count操作add10k。

    /**
     * 成员变量是线程共享的,当线程同时对count操作时可能会导致数据不可见的问题
     */
    private static long count;

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> { add10k(); });
        Thread thread2 = new Thread(() -> { add10k(); });

        thread1.start();
        thread2.start();
        //等待线程执行结束
        thread1.join();
        thread2.join();
        System.out.println(count);
    }


    private static void add10k() {
        int idx = 0;  //局部变量是维护到各自线程里的,所以是线程安全的
        while (idx++ < 10000) {
            /**
             * 最终的结果是两个线程的idx会=10k,
             * 而count因为线程有时不可见,导致相同的结果会执行,最终会小于20000
             */
            count += 1;
        }
    }

对于多核时代,每颗 CPU 都有自己的缓存,每个线程操作自己对应的CPU缓存。

假设线程A和B同时开始执行,第一次将内存里的count=0,加载到各自CPU的缓存中,执行count+=1之后,各自CPU的缓存都为1,然后同时写入内存,这时我们发现内存里是1,而不是2。。。

同时,由于此时各自线程的CPU缓存里都有了count值(线程基于缓存计算),虽然两个线程最终都会执行10k次,但count结果是小于20k的。

小结一下:

因为共享变量会加载到线程对应CPU的缓存中对变量进行操作,而不同CPU的缓存是相互不可见的,所以最后当共享变量写到内存中时,结果就会差强人意。
在这里插入图片描述

2.2. (分时复用引起的)原子性问题

原子性:把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。
时间片:操作系统允许某个线程执行一小段时间,例如 50毫秒,过了 50 毫秒会重新选择线程来执行,这个 50 毫秒称为“时间片”。

从上面的概念我们知道,时间片的切换可能会破坏原子性。

接下来看一个简单的例子:两个线程都执行一次count += 1操作。count += 1执行需要下面三条CPU指令。

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
  • 指令 2:之后,在寄存器中执行 +1 操作
  • 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

假设线程A执行到 指令 1,然后线程B开始执行这三条指令,执行完后切换到线程A,此时A得到的结果是1,而不是2.

2.3. (重排序引起的)有序性问题

左图展示了单例创建的逻辑,程序在执行之前会进行指令重排序。具体的:
在这里插入图片描述
在 new 操作上,优化后的执行路径是这样的:

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

假设两个线程A、B同时执行代码,当线程A执行到第二步,然后切换到B执行,此时判断对象不为空,返回未初始化的对象,导致调用报错。


3. 实现线程安全

3.1 互斥同步

互斥:同一时刻只有一个线程执行临界区。把一段需要互斥执行的代码称为临界区。
实现互斥:synchronized 和 ReentrantLock。


3.2. 非阻塞同步CAS

互斥同步会带来线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题

乐观的非阻塞同步 CAS

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略: 先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。

比较并交换(Compare-and-Swap,CAS):冲突检测的实现

CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V的值更新为 B。

具体过程看一个例子:

在内存地址V当中,存储着值为10的变量。
 
此时线程1想要把变量的值增加1。对线程1来说,旧的预期值A=10,要修改的新值B=11。
线程1要提交更新之前,线程2抢先一步,把内存地址V中的变量值率先更新成了11。线程1开始提交更新,首先进行A和地址V的实际值比较(Compare),发现A不等于V的实际值,提交失败。
 
线程1重新获取内存地址V的当前值,并重新计算想要修改的新值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋。
这一次比较幸运,没有其他线程改变地址V的值。线程1进行Compare,发现A和地址V的实际值是相等的。
线程1进行SWAP,把地址V的值替换为B,也就是12。

CAS底层如何实现?
利用unsafe提供的原子性操作方法。


CAS的缺点:

缺点描述
CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。
不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。
ABA问题
当一个值从A更新成B,又更新会A,普通CAS机制会误判通过检测。 利用版本号比较可以有效解决ABA问题,A-B-A就变成1A-2B-3A。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。
AtomicInteger

AtomicInteger是一个支持原子操作的 Integer 类,就是保证对AtomicInteger类型变量的增加和减少操作是原子性的,不会出现多个线程下的数据不一致问题

如果不使用 AtomicInteger,要实现一个按顺序获取的ID,就必须在每次获取时进行加锁操作,以避免出现并发时获取到同样的ID的现象。

看下 AtomicInteger 的 incrementAndGet()

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current +1;
        if(compareAndSet(current, next))
            return next;
    }
}
//为 native 方法,compareAndSwapInt 基于的是 CPU 的 CAS 指令(硬件的机器指令?)来实现的。
//基于 CAS 的操作可认为是无阻塞的,一个线程的失败或挂起不会引起其它线程也失败或挂起。
//并且由于 CAS 操作是 CPU 原语,所以性能比较好。

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

JDK 的 rt.jar 包中的 Unsafe 类提供了硬件级别的原子性操作,Unsafe 类中的方法都是 native 方法,它们使用 JNI 的方式访问本地 C++ 实现库


3.3 无同步方案

方面解释
局部变量栈封闭
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
线程本地存储(ThreadLocal)
当使用ThreadLocal来维护变量时, ThreadLocal会为每个线程创建单独的变量副本, 避免因多线程操作共享变量而导致的数据不一致的情况。
可重入代码(Reentrant Code)
这种代码可以在执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。
特点:不依赖堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。



参考:
https://pdai.tech/md/java/thread/java-thread-x-overview.html
极客时间-Java并发编程实战

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
学习Java并发编程是一项需要投入大量时间和精力的任务,但它在现代软件开发中是不可或缺的。通过深入学习线程、同步和异步、互斥和死锁、线程安全性、并发编程模型以及Java并发包等内容,您可以成为一名更加优秀的编程专家。 对于初学者来说,Java并发编程可能会显得晦涩难懂,并且涉及了许多新技术。在学习这一领域之前,建议掌握好相关的理论知识,并打好基础。只有在掌握了基础知识后,才能更好地理解和应用更高层次的概念。 在进行Java并发编程的训练时,以下是一些建议: 1. 了解并掌握Java中的线程机制和线程生命周期。 2. 学习如何使用同步和异步机制来处理并发编程中的数据共享和通信问题。 3. 理解并学习如何使用锁、互斥和死锁的概念以及如何避免它们。 4. 熟悉线程安全性的概念和相关的技术,如volatile关键字和Atomic类。 5. 学习并理解Java提供的并发编程模型,如线程池和Fork/Join框架。 6. 了解并掌握Java并发包中提供的各种类和工具,如Semaphore、CountDownLatch和CyclicBarrier等。 通过不断学习和练习,并发编程的技能会得到提升。建议您多实践编写并发程序,通过解决实际问题来加深对并发编程的理解。祝您在学习Java并发编程的过程中取得成功!<span class="em">1</span><span class="em">2</span> #### 引用[.reference_title] - *1* *2* [如何学习Java并发编程](https://blog.csdn.net/weixin_42080277/article/details/129785094)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 100%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

roman_日积跬步-终至千里

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值