java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Java多线程并发基础

Java多线程并发基础实战举例

作者:键盘侠Gu

进程与线程的区别,解释并行与并发的概念,分析多线程导致的线程安全问题(可见性、原子性、有序性),并探讨Java通过volatile、synchronized及Happens-Before规则解决这些问题的机制,最后分类线程安全程度及实现方案,感兴趣的朋友一起看看吧

1、进程与线程

1.1 进程

进程(Process)是计算机中正在运行的程序。程序是一种静态的概念,而进程是程序再执行过程中创建的动态实体。每个进程都有自己的内存空间、代码、数据和资源,他也是操作系统进行任务调度和资源分配的基本单位。

多个进程可以同事运行在计算机上,彼此独立并且互不干扰。操作系统通过进程管理来控制和监视进程的创建、运行、暂停和终止等操作。进程还可以通过进程间通信机制来实现进程之间的数据交换和同步。

打开windows的任务管理其,运行的程序就是一个个进程。

1.2 线程

线程(Thread)是程序执行的最小单位,它是进程的一部分。一个进程可以包含一个或多个线程。同一个进程中的多个线程共享同一块内存空间和其他资源,他们可以同时执行不同的任务。每个线程都有自己的程序计数器、栈和一组寄存器,这使得线程能够独立的执行代码。

线程的特点

线程的使用可以提升程序的性能和响应性,特别是再多核处理器上可以实现并行计算。但多线程编程也需要注意线程间的同步和共享数据的安全性问题,以避免出现竟态条件和数据不一致的情况。

2、并行与并发

并行是多个任务同时进行,而并发是多个任务交替进行,并且可能存在资源竞争和依赖。

并行和并发之间的区别在于任务是否可以 同时执行以及是否需要竞争共享资源。并行通常需要硬件支持,如多核处理器,能同时执行多个任务。而并发则可以在单核处理器上通过时间片轮转等技术实现。

在实际应用中,可以用并行提高计算性能和执行速度,可适用于多线程编程或分布式计算;而并发则可以提高资源利用率和系统吞吐量,可适用于任务调度和资源管理。

3、多线程的必要性

我们先来看个JVM(Java内存模型)简单模型

Java内存模型

3.1 CPU缓存优化

CPU增强缓存,会导致可见性问题。

3.2 操作系统优化

操作系统增加了进程和线程的概念来实现分时复用CPU资源,进而均衡CPU和I/O设备的速度差异。

操作系统增加了进程、线程,会导致原子性问题。

3.3 编译程序优化

编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

编译程序优化指令执行次序是指在编译过程中,编译器根据各种优化策略和规则,自动调整和重排程序中指令的顺序,以提高程序运行的效率和性能。

编译器进行指令优化的主要目的是为了解决以下问题:

通过这些优化,编译器可以帮助程序员在不需要手动干预地情况下自动提高程序地运行效率和性能,使得程序在各种硬件平台上都能获得更好的运行效果。

编译程序优化指令执行次序,会导致有序性问题。

4、线程安全问题

如果多个线程对同一个共享数据进行访问而不采取同步操作地的话,那么操作的结果是不一致的。

以下代码演示了1000个线程同时对cnt执行自增操作,操作结束之后它的值有可能啸宇1000。

public class ThreadUnsafeExample {
    private int cnt = 0;
    public void add() {
        cnt++;
    }
    public int get() {
        return cnt;
    }
}
public static void main(String[] args) throws InterruptedException {
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
        executorService.execute(() -> {
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}
// 输出结果:992

为什么多线程会导致结果啸宇1000呢?示什么导致 线程安全问题?

5、线程安全导致原因

多线程操作共享变量时,才会引起线程安全问题。所以下面操作的变量默认都是共享变量。

5.1 可见性

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

CPU缓存会导致可见性规则打破,案例:

//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;

线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。所以CPU缓存会导致可见性问题

5.2 原子性

原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

分时复用会引起原子性打破,案例:

int i = 1;
// 线程1执行
i += 1;
// 线程2执行
i += 1;

这里需要注意的是:i += 1需要三条CPU指令。

由于CPU分时复用(线程切换)的存在,线程1执行了第一条指令后,就切换到线程2执行,例如线程2执行了这三条指令后,再切换会线程1执行后续两条指令,将造成最后写道内存中的 i 值是2而不是3。所以 CPU分时复用会导致原子性问题

5.3 有序性

有序性:即程序执行的顺序按照代码的县厚顺序执行。

重排序优化会打破程序有序性,案例:

int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

虽然上述代码语句2在语句1之后,但是多线程执行顺序不一定按语句1、语句2这个顺序执行。重排序是指计算机系统(如处理器、编译器等)在执行程序时,按照某种规则重新调整指令或操作的顺序,以提高程序性能或满足其他需求。

重排序可以分为三种类型:编译器重排序、处理器重排序和内存系统重排序。

重排序在一定程度上可以提高程序地执行速度和效率,但必须在确保程序正确性和语义一致性地前提下进行。在并发编程中,重排序可能会引发数据竞争、原子性问题等多线程并发问题,因此需要采取同步和内存屏障等手段进行控制和保护。

编译程序优化地重排序下,程序地有序性会打破。所以编译器优化和处理器重排序可能会导致有序性问题。

Java重排序流程:

6、Java解决并发问题

Java需要解决多线程并发安全问题,就需要解决 可见性、有序性、原子性 这三个问题。那么,Java是如何解决的呢?

6.1 volatile、synchronized 和 final 关键字

这几个关键字,后面会进行详解,这里先看下概念:

6.2 可见性、有序性、原子性的理解

6.3 Happens-Before 规则

Java内存模型(JMM)通过Happens-Before规则用于确保多线程环境下地可见性和有序性,制定了对于一个操作地结果,在另一个操作中地可见性和有序性地保证。

Java中地Happens-Before规则:

7、线程安全程度

线程安全可以从强到弱分为以下几个级别:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

8、线程安全实现

8.1 互斥同步

互斥同步是一种保证多个线程在访问共享资源时的互斥性的机制,以防止竞态条件和数据不一致问题。

核心

互斥同步的详解

互斥同步的主要问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步

8.2 非阻塞同步

阻塞同步采用的是悲观策略,认为一个线程在修改时,一定会有其他线程进行访问修改,导致数据不一致。

非阻塞同步采用的是乐观策略,认为一个线程在修改时,不会有其他线程进行访问修改,那就修改成功了,如果有其他线程修改,那就采取步长措施(不断地重试,直到成功为止)。一个线程在进行某段特定代码(临界区)操作时,其他线程可以进行其他代码地操作,不需要进行等待。

8.2.1 CAS

CAS(Compare And Swap)是用于解决多线程环境下地并发问题地一个方案(非阻塞同步方案),保证原子性。

通过比较一个内存位置地值与预期值,如果相等,则将新值写入该内存位置,否则不做任何操作。

CAS 主要应用于一些需要高并发和原子性操作的场景,比如非阻塞算法、无锁队列和乐观锁等。在 Java 中,java.util.concurrent.atomic 包提供了一些原子类,如 AtomicInteger 和 AtomicLong,它们底层使用了 CAS 来实现线程安全的操作。

8.2.2 ABA

如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。

在使用 CAS 时可能存在ABA问题,也就是说即使内存位置的值已经变化,但其实际含义对当前线程来说是没有变化的。为了解决ABA问题,可以使用版本号或引用的方式进行解决,比如 AtomicStampedReference 和 AtomicMarkableReference 类。

8.3 无同步方案

要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。

8.3.1 栈封闭

多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。

public class StackClosedExample {
    public void add100() {
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
            cnt++;
        }
        System.out.println(cnt);
    }
}
public static void main(String[] args) {
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}
/**
 * 输出结果:
 * 100
 * 100
 */

8.3.2 线程本地存储

如果能保证共享变量每次使用时,都在同一个线程中执行,那么就没有线程安全问题可言。

8.3.3 可重入代码

可重入代码(reentrant code)是指可以由多个任务并发使用,且不会引发数据错误的代码。换言之,一个可重入的程序、函数或例程在执行过程中被中断,然后在中断返回前再次调用,它都将产生可预期的结果。

可重入性是一个重要的概念,尤其是在多线程或多任务的并发编程环境中,它确保了代码的执行不会被其他线程或任务的干扰。

要编写可重入代码,就必须避免使用全局变量、静态变量或其他非局部的状态,也需要避免调用非重入的函数,并确保对互斥对象的访问(如锁)是正确的。

到此这篇关于Java多线程并发基础实战举例的文章就介绍到这了,更多相关Java多线程并发基础内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文