快捷搜索: 长连接 前端 源码 pan

JAVA并发之多线程引发的问题剖析以及如何保证线程安全

JAVA多线程中的各种问题剖析

首先开始之前 需要提及一下前置章节 能够更加深入了解本节所讲

首先我们来说一下并发的优点,根据优点特性,引出并发应当注意的安全问题

1并发的优点

技术在进步,CPU、内存、I/O 设备的性能也在不断提高。但是,始终存在一个核心矛盾:CPU、内存、I/O 设备存在速度差异。CPU 远快于内存,内存远快于 I/O 设备。 根据木桶短板理论可知,一只木桶能装多少水,取决于最短的那块木板。程序整体性能取决于最慢的操作——I/O,即单方面提高 CPU 性能是无效的。

为了合理利用 CPU 的高性能,平衡这三者的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

    CPU 增加了缓存,以均衡与内存的速度差异; 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异; 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

其中,进程、线程使得计算机、程序有了并发处理任务的能力,它有两个重要优点:

    提升资源利用率 降低程序响应时间

1.1提升资源利用率

从磁盘中读取文件的时候,大部分的 CPU 时间用于等待磁盘去读取数据。在这段时间里,CPU 非常的空闲。它可以做一些别的事情。通过改变操作的顺序,就能够更好的使用 CPU 资源 ,使用并发方式不一定就是磁盘IO,也可以是网络IO和用户输入等,但是不管是哪种IO 都比CPU 和内存IO慢的多.线程并不能提高速度,而是在执行某个耗时的功能时,在还可以做其它的事。多线程使你的程序在处理文件时不必显得已经卡死.

1.2降低程序响应时间

为了使程序的响应时间变的更短,使用多线程应用程序也是常见的一种方式将一个单线程应用程序变成多线程应用程序的另一个常见的目的是实现一个响应更快的应用程序。设想一个服务器应用,它在某一个端口监听进来的请求。当一个请求到来时,它去处理这个请求,然后再返回去监听。

服务器的流程如下所述:

public class SingleThreadWebServer {
          
   
    public static void main(String[] args) throws IOException{
          
   
        ServerSocket socket = new ServerSocket(80);
        while (true) {
          
   
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }
}
public class ThreadPerTaskWebServer {
          
   
    public static void main(String[] args) throws IOException {
          
   
        ServerSocket socket = new ServerSocket(80);
        while (true) {
          
   
            final Socket connection = socket.accept();
            Runnable workerThread = new Runnable() {
          
   
                public void run() {
          
   
                    handleRequest(connection);        
                }    
            };    
         }  
     } 
}

这种方式,服务端线程迅速地返回去监听。因此,更多的客户端能够发送请求给服务端。这个服务也变得响应更快。

2并发带来的安全性问题

并发安全是指 保证程序在并发处理时的结果 符合预期 并发安全需要保证3个特性:

**原子性:**通俗讲就是相关操作不会中途被其他线程干扰,一般通过同步机制(加锁:sychronized、Lock)实现。

**有序性:**保证线程内串行语义,避免指令重排等

可见性:一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将线程本地状态反映到主内存上,volatile 就是负责保证可见性的

2.1 原子性问题

早期,CPU速度比IO操作快很多,一个程序在读取文件时,可将自己标记为"休眠状态"并让出CPU的使用权,等待数据加载到内存后,操作系统会唤醒该进程,唤醒后就有机会重新获得CPU使用权. 这些操作会引发进程的切换,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址. 而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了 所以我们现在提到的任务切换都是指线程切换

高级语言里一条语句,往往需要多个 CPU 指令完成,如: count += 1,至少需要三条 CPU 指令
指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器; 指令 2:之后,在寄存器中执行 +1 操作; 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。

原子性问题出现:

对于上面的三条指令来说,我们假设 count=0,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。

我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,这是违背我们直觉的地方。因此,很多时候我们需要在高级语言层面保证操作的原子性。

2.2有序性问题

顾名思义,有序性指的是程序按照代码的先后顺序执行。编译器为了优化性能,有时候会改变程序中语句的先后顺序

举个例子:

双重检查创建单例对象,在获取实例 getInstance() 的方法中,我们首先判断 instance 是否为空,如果为空,则锁定 Singleton.class 并再次检查 instance 是否为空,如果还为空则创建 Singleton 的一个实例.

public class Singleton {
          
   
  static Singleton instance;
  static Singleton getInstance(){
          
   
    if (instance == null) {
          
   
      synchronized(Singleton.class) {
          
   
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

线程A,B如果同时调用getInstance()方法获取实例,他们会同时检查到instance 为null ,这时会将Singleton.class进行加锁操作,此时jvm保证只有一个锁上锁成功,另一个线程会等待状态;假设线程A加锁成功,这时线程A会new一个实例之后释放锁,线程B被唤醒,线程B会再次加锁此时加锁成功,线程B检查实例是否为null,会发现已经被实例化,不会再创建另外一个实例.

这段代码和逻辑看上去没有问题,但实际上getInstance()方法还是有问题的,问题在new的操作上,我们认为的new操作应该是:

1.分配内存 2.在这块内存上初始化Singleton对象 3.将内存地址给instance变量

但是实际jvm优化后的操作是这样的:

1分配内存 2将地址给instance变量 3在内存上初始化Singleton对象

优化后会导致 我们这个时候另一个线程访问 instance 的成员变量时获取对象不为null 就结束实例化操作 返回instance 会触发空指针异常。

2.3可见性问题

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

现代多核心CPU,每个核心都有自己的缓存,多个线程在不同的CPU核心上执行时,线程操作的是不同的CPU缓存,

线程不安全的示例

下面的代码,每执行一次 add10K() 方法,都会循环 10000 次 count+=1 操作。在 calc() 方法中我们创建了两个线程,每个线程调用一次 add10K() 方法,我们来想一想执行 calc() 方法得到的结果应该是多少呢?

class Test {
          
   
    private static long count = 0;
    private void add10K() {
          
   
        int idx = 0;
        while(idx++ < 10000) {
          
   
            count += 1;
        }
    }

    public static  long getCount(){
          
   
        return count;
    }
    public static void calc() throws InterruptedException {
          
   
        final Test test = new Test();
        // 创建两个线程,执行 add() 操作
        Thread th1 = new Thread(()->{
          
   
            test.add10K();
        });
        Thread th2 = new Thread(()->{
          
   
            test.add10K();
        });
        // 启动两个线程
        th1.start();
        th2.start();
        // 等待两个线程执行结束
        th1.join();
        th2.join();
    }

    public static void main(String[] args) throws InterruptedException {
          
   
        Test.calc();
        System.out.println(Test.getCount());
        //运行三次 分别输出 11880 12884 14821
    }
}

直觉告诉我们应该是 20000,因为在单线程里调用两次 add10K() 方法,count 的值就是 20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?

我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2。之后由于各自的 CPU 缓存里都有了 count 的值,两个线程都是基于 CPU 缓存里的 count 值来计算,所以导致最终 count 的值都是小于 20000 的。这就是缓存的可见性问题。

循环 10000 次 count+=1 操作如果改为循环 1 亿次,你会发现效果更明显,最终 count 的值接近 1 亿,而不是 2 亿。如果循环 10000 次,count 的值接近 20000,原因是两个线程不是同时启动的,有一个时差。

3如何保证并发安全

了解保证并发安全的方法,首先要了解同步是什么:

同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程访问

实现保证并发安全有下面3种方式:

1.阻塞同步(悲观锁):

阻塞同步也称为互斥同步,是常见并发保证正确性的手段,临界区(Critical Sections)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式 最典型的案例是使用 synchronized 或 Lock 。 互斥同步最主要的问题是线程阻塞和唤醒所带来的性能问题,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。

2.非阻塞同步(乐观锁)

基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步 乐观锁指令常见的有: 测试并设置(Test-amd-Set) 获取并增加(Fetch-and-Increment) 交换(Swap) 比较并交换(CAS) 加载链接、条件存储(Load-linked / Store-Conditional) Java 典型应用场景:J.U.C 包中的原子类(基于 Unsafe 类的 CAS (Compare and swap) 操作)

3.无同步

要保证线程安全,不一定非要进行同步。同步只是保证共享数据争用时的正确性,如果一个方法本来就不涉及共享数据,那么自然无须同步。 Java 中的 无同步方案 有: 可重入代码 - 也叫纯代码。如果一个方法,它的 返回结果是可以预测的,即只要输入了相同的数据,就能返回相同的结果,那它就满足可重入性,程序可以在被打断处继续执行,且执行结果不受影响,当然也是线程安全的。 线程本地存储 - 使用 ThreadLocal 为共享变量在每个线程中都创建了一个本地副本,这个副本只能被当前线程访问,其他线程无法访问,那么自然是线程安全的。

4总结

为了并发的优点 我们选择了多线程,多线程并发给我们带来了好处 也带来了问题,处理这些安全性问题我们选择加锁让共享数据同时只能进入一个线程来保证并发时数据安全,这时加锁也为我们带来了诸多问题 如:死锁,活锁,线程饥饿等问题

经验分享 程序员 微信小程序 职场和发展