前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >java并发线程实战(1) 线程安全和机制原理

java并发线程实战(1) 线程安全和机制原理

作者头像
黄规速
发布2022-04-14 15:19:00
5610
发布2022-04-14 15:19:00
举报
文章被收录于专栏:架构师成长之路

一、什么叫线程安全?

Google搜索就会出现许多像这样的“定义”:

多个线程同时执行也能工作的代码就是线程安全的代码 如果一段代码可以保证多个线程访问的时候正确操作共享数据,那么它是线程安全的

《Java 并发编程实战》实战的定义:

当多个线程访问某各类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

线程安全类中封装了必要的同步机制,因此客户端无须进一步采取同步措施。

Servlet是单实例多线程的,如果编写的servlet是无状态的(只有局部变量, 是线程安全。如果编写的servlet是有状态的(有共享成员变量)是不安全的。当多个线程同时访问同一个方法,是不能保证共享变量的线程安全性的。

(我个人理解不能简单说Servlet是单实例线程不安全。)

Struts2 的 action 是多实例多线程的,是线程安全的,每个请求过来都会 new 一个新的 action 分配给这个请求,请求完成后销毁。

SpringMVC 的 Controller 是线程安全的吗?和 Servlet 类似的处理流程。

Struts2 好处是不用考虑线程安全问题;Servlet 和 SpringMVC 需要考虑线程安全问题,但是性能可以提升不用处理太多的 gc,可以使用 ThreadLocal 来处理多线程的问题。

二、前言:线程安全的问题

我们先看看一段代码:

代码语言:javascript
复制
package com.demo.springboot2.web.service;

/**
 * Created by huangguisu on 2021/1/1.
 */
import java.util.concurrent.CountDownLatch;
public class TestAdd {
    private int count = 0;

    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(4);
        TestAdd add = new TestAdd();
        add.doAdd(countDownLatch);
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(add.getCount());

    }
    public void doAdd(CountDownLatch countDownLatch) {
        for (int i = 0; i < 4; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        count++;
                    }
                    countDownLatch.countDown();
                }
            }).start();
        }
    }

    public int getCount() {
        return count;
    }
}

上面是一个把变量自增4000次的例子,只不过用了4个线程,每个线程自增1000次,用CountDownLatch等4个线程执行完,打印出最终结果。实际上,我们希望程序的结果是4000,但是打印出来的结果并非总是4000。

出现线程安全的问题本质是因为:

主内存和工作内存数据不一致性以及编译器重排序导致。

三、Java内存模型(JMM)数据可见性问题、指令重排序、内存屏障

1、简单理解CPU

CPU除了控制器、运算器等器件还有一个重要的部件就是寄存器。其中寄存器的作用就是进行数据的临时存储。

CPU的运算速度是非常快的,为了性能CPU在内部开辟一小块临时存储区域,并在进行运算时先将数据从内存复制到这一小块临时存储区域中,运算时就在这一小快临时存储区域内进行。我们称这一小块临时存储区域为寄存器。

CPU读取指令是往内存里面去读取的,读一条指令放到CPU中,CPU去执行,对内存的读取速度比较慢,所以从内存读取的速度去决定了这个CPU的执行速度的。

一般CPU的计算速度比内存快几个数量级,为了平衡CPU和内存之间的矛盾,引入的高速缓存机制,如ARM A11的处理器,它的1级缓存中的容量是64KB,2级缓存中的容量是8M, 通过增加cpu高速缓存的机制,以此弥补服务器内存读写速度的效率问题;

处理器、高速缓存、主内存的交互关系如下:

当系统运行时,CPU执行计算的过程如下:

  1. 程序以及数据被加载到主内存
  2. 指令和数据被加载到CPU缓存
  3. CPU执行指令,把结果写到高速缓存
  4. 高速缓存中的数据写回主内存

2、内存屏障(memory barriers)

内存屏障:一组处理器指令,用于实现对内存操作的顺序限制。是被插入两个CPU指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将写缓冲器的值写入高速缓存,清空无效队列,从而“附带”的保障了可见性。总结: 内存屏障(Memory Barrier)是一个CPU指令,两个作用: 1.保证特定操作的执行顺序 2.保证某些变量的内存可见性

如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片摘自《深入理解计算机系统》):

试想下面一种情况:

  1. 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
  2. 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
  3. 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
  4. 核3访问该字节,由于核0并未将数据写回主存,数据不同步

为了解决这个问题,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。

3、Java的内存模型JMM(Java Memory Medel)

JVM虚拟计算机平台就类似于一个操作系统的角色,所以在具体实现上JVM虚拟机也的确是借鉴了很多操作系统的特点;

JAVA中线程的工作空间(working memory)就是CPU的寄存器和高速缓存的抽象描述,cpu在计算的时候,并不总是从内存读取数据,它的数据读取顺序优先级 是:寄存器-高速缓存-内存;

而在JAVA的内存模型中也是同等的,Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(类似于CPU的高速缓存),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量,操作完成后再将变量写回主内存。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。基本关系如下图:

注意:这里的Java内存模型,主内存、工作内存与Java内存区域模型的Java堆、栈、方法区不是同一层次内存划分,这两者基本上没有关系。 Java虚拟机运行时内存的区域划分 方法区:存储类信息、常量、静态变量等,各线程共享 虚拟机栈:每个方法的执行都会创建栈帧,用于存储局部变量、操作数栈、动态链接等,虚拟机栈主要存储这些信息,线程私有 本地方法栈:虚拟机使用到的Native方法服务,例如c程序等,线程私有 程序计数器:记录程序运行到哪一行了,相当于当前线程字节码的行号计数器,线程私有 堆:new出的实例对象都存储在这个区域,是GC的主战场,线程共享。 所以对于JMM定义的主内存,大部分时候可以对应堆内存、方法区等线程共享的区域,这里只是概念上对应,其实程序计数器、虚拟机栈等也有部分是放在主内存的,具体看虚拟机的设计。

多线程如何通过共享变量通信

1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。

2)线程B到主内存中去读取线程A之前已更新过的共享变量。

4、重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:

单线程环境里面确保程序最终执行结构和代码顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的数据依赖性。

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的结果无法预测。

public void func() { int x = 1; //A int y = 2; //B int x = x + 3; //C int y = x * 4; //D } 程序可能执行顺序:ABCD,BACD,ACBD。当然由于数据依赖性(y依赖于x),语句D无法排到第一个。

从上面内存屏障(Memory Barrier)的两个作用:1.保证特定操作的执行顺序 2.保证某些变量的内存可见性。

如果在指令间插入一条内存屏障(Memory Barrier)则会告诉编译器和CPU,不管什么指令都不能和这条内存屏障指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。另一个作用是强制刷新各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

多线程在执行过程中,数据的不可见性,原子性,以及重排序所引起的指令有序性 三个问题基本是多线程并发问题的三个重要特性,也就是我们常说的:

并发的三大特性:原子性,有序性,可见性; 原子性:代码操作是否是原子操作(如:i++ 看似一个代码片段,实际的执行中将会分为三步执行,则必然是非原子化的操作,在多线程的场景中则会出现异常) 有序性:CPU执行代码指令时的有序性; 可见性:由于工作线程的内存与主内存的数据不同步,而导致的数据可见性问题;

四、导致并发线程安全的原因总结

1、原子性

原子性说的就是某一段执行逻辑,具有不可拆分的特性,要么全部执行,要么全部不执行,不存在执行到一半放弃CPU权限的情况。就像日常生活中银行交易系统一样,取钱和扣款必须是一个原子操作,不能存在只取钱不扣款的情况,或者是只扣款不取钱的情况。

2、重排序

在实际的编码过程中,开发人员写出来的程序代码,它的执行顺序和具体编译后执行的顺序可能不太一样,这个主要是编译器以及计算机内部有一个优化执行效果的过程,用于提高程序执行性能,它有一个原则:如果两个步骤之间不存在依赖的关系,同时在不改变单线程程序语义的情况下,允许重新安排指令的执行顺序

一般有三种重排序:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

上面的后两种重排序属于处理器级别的重排序。同时重排序也是基于一定的规则的,SR-133模型主要就是为了阐述基于内存的操作和它们之间的可见性原则,即:如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
  • 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。

注意:这里的happens-before仅仅只是要求前一个操作(执行的结果)对后一个操作可见,并不要求前一个操作必须要在后一个操作之前执行,这里我的理解是:如果存在step1和step2两个步骤的操作,但是两个操作互不影响,虽然step1 happens before step2,但是实际中如果发生重排序,可能会出现step2 先于step1执行,因为这里的执行结果与happens-before的结果一致,JMM就会认为它是合法的。

3、可见性

所谓可见性:就是指在多线程环境中,某个数据被其中一条线程修改了,能够立即被其他线程感知到。

volatile关键字主要用于修饰变量,在Java中的作用就相当于强制给代码加了一个内存屏障指令,被它修饰的变量,在线程执行时,如果读取该变量,会强制到主内存中去读取最新的值到工作空间中,而不是先到工作空间中区读取线程缓存的值;对应的,如果对变量进行写操作,会强制将更新后的变量刷新回主内存中,保证线程间的可见性。

在JMM的控制下,用volatile修饰的变量在一定程度上是不会进行重排序的,这也从微观上对volatile字段进行了一致性的加强。但是volatile仅仅只是加了一个内存屏障指令,也就是说对于数据在线程之间的可见性是可以保障的,比如线程t1对变量x的修改,其他线程在读取x变量的值时,可以获取到最新变化的值;可是它却无法保障原子性,即所谓的:要么全执行,要么全不执行。

举例:x++操作,在指令层面大致可以分为三步,读取x值,对读取到的值加1,将加1后的值写回x,现两个线程同时对初始值为0x执行加加操作,各5000次之加加后,得到的结果一般会小于10000。归根结底就是:只要是指令层面无法一条指令能够完成的,如果没有特殊手段,都会存在原子性问题。

一般volatile主要用于相互之间不存在依赖关系的语句中,上面x++的例子之所以有问题,就是因为对x++的操作是与上一步操作的结果相依赖的。所以在并发环境下一般volatile用于修饰boolean变量,它的值只能是truefalse,利用它的变化,来实现一些其他操作,因为它不会存在相互依赖的关系。

五、上面示例分析

我们来分析一下,上面的程序为什么没得到正确的结果。请看下图,线程A、B同时去读取主内存的count初始值存放在各自的工作内存里,同时执行了自增操作,写回主内存,最终得到了错误的结果。

我们再来深入分析一下,造成这个错误的本质原因:

(1)、可见性,工作内存的最新值不知道什么时候会写回主内存

(2)、有序性,线程之间必须是有序的访问共享变量,我们用“视界”这个概念来描述一下这个过程,以B线程的视角看,当他看到A线程运算好之后,把值写回之内存之后,马上去读取最新的值来做运算。A线程也应该是看到B运算完之后,马上去读取,在做运算,这样就得到了正确的结果。

接下来,我们来具体分析一下,为什么要从可见性和有序性两个方面来限定。

给count加上volatile关键字,就保证了可见性。

代码语言:javascript
复制
private volatile int count = 0;

volatile关键字,会在最终编译出来的指令上加上lock前缀,lock前缀的指令做三件事情

(1)、防止指令重排序

(2)、锁住总线或者使用锁定缓存来保证执行的原子性,早期的处理可能用锁定总线的方式,这样其他处理器没办法通过总线访问内存,开销比较大,现在的处理器都是用锁定缓存的方式,在配合缓存一致性来解决。

(3)、把缓冲区的所有数据都写回主内存,并保证其他处理器缓存的该变量失效。

既然保证了可见性,加上了volatile关键词,为什么还是无法得到正确的结果:

原因是count++,并非原子操作,count++等效于如下步骤:

(1)、 从主内存中读取count赋值给线程副本变量: temp=count

(2)、线程副本变量加1 : temp=temp+1

(3)、线程副本变量写回主内存 :count=temp

就算是真的严苛的给总线加锁,导致同一时刻,只能有一个处理器访问到count变量,但是在执行第(2)步操作时,其他cpu已经可以访问count变量,此时最新运算结果还没刷回主内存,造成了错误的结果,所以必须保证顺序性。

那么保证顺序性的本质,就是保证同一时刻只有一个CPU可以执行临界区代码。这时候做法通常是加锁,锁本质是分两种:悲观锁和乐观锁。如典型的悲观锁synchronized、JUC包下面典型的乐观锁ReentrantLock。

我们通过AtomicInteger原子操作类确保原子性操作:

代码语言:javascript
复制
package com.demo.springboot2.web.service;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * Created by huangguisu on 2021/1/1.
 */
import java.util.concurrent.CountDownLatch;
public class TestAdd {
    private volatile int count = 0;
    public static AtomicInteger atomicIntegerCount = new AtomicInteger(0);
    public void doAdd(CountDownLatch countDownLatch) {
        for (int i = 0; i < 4; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int j = 0; j < 1000; j++) {
                        count++;
                        atomicIntegerCount.incrementAndGet();
                    }
                    countDownLatch.countDown();
                }
            }).start();
        }
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        CountDownLatch countDownLatch = new CountDownLatch(4);
        TestAdd add = new TestAdd();
        add.doAdd(countDownLatch);
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("volatile count:"+ add.getCount());
        System.out.println("atomicIntegerCount:"+ atomicIntegerCount.get());

    }

}

结果稳稳的输出4000。至于为什么AtomicInteger能保证原子性,主要是使用了CAS:

总结一下:要保证线程安全,必须保证两点:共享变量的可见性、临界区代码访问的顺序性。

五、保证线程安全机制有哪些?

1、synchronized和lock加锁:

a、锁能使其保护的代码以串行的形式来访问,当给一个复合操作加锁后,能使其成为原子操作。一种错误的思想是只 要对写数据的方法加锁,其实这是错的,对数据进行操作的所有方法都需加锁,不管是读还是写 b、加锁时需要考虑性能问题,不能总是一味地给整个方法加锁synchronized就了事了,应该将方法中不影响共享状态且执行时间比较长的代码分离出去 c、加锁的含义不仅仅局限于互斥,还包括可见性。为了确保所有线程都能看见最新值,读操作和写操作必须使用同样的锁对象。

2、不共享状态: 无状态对象: 无状态对象一定是线程安全的,因为不会影响到其他线程 线程关闭: 仅在单线程环境下使用

3、不可变对象:

可以使用final修饰的对象保证线程安全,由于final修饰的引用型变量(除String外)不可变是指引用不可变,但其指向的对象是可变的,所以此类必须安全发布,也即不能对外提供可以修改final对象的接口

六、保证线程安全机制案例

我们需要一个单例模式:一个类有且仅有一个实例,并且自行实例化向整个系统提供。

Singleton懒汉模式:

代码语言:javascript
复制
public class Singleton {
    public static  Singleton singleton;

    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
}

如上一个简单的懒汉单例模式,按照对象的构造过程,实例化一个对象1、可以分为三个步骤(指令): 1、 分配内存空间。 2、 初始化对象。 3、 将内存空间的地址赋值给对应的引用。 但是由于操作系统可以对指令进行重排序,所以上面的过程也可能变为如下的过程: 1、 分配内存空间。 2、 将内存空间的地址赋值给对应的引用。 3、 初始化对象 。

所以,如果出现并发访问getInstance()方法时,则可能会出现,线程二判断singleton是否为空,此时由于当前该singleton已经分配了内存地址,但其实并没有初始化对象,则会导致return 一个未初始化的对象引用暴露出来,以此可能会出现一些不可预料的代码异常;

当然,指令重排序的问题并非每次都会进行,在某些特殊的场景下,编译器和处理器是不会进行重排序的,但上述的举例场景则是大概率会出现指令重排序问题。

要想确保线程安全,可以通过饿汉模式来

代码语言:javascript
复制
class SingleOne{
   // 创建类中私有构造
    private SingleOne(){

    }
    // 创建私有对象
    private static SingleOne instance = new SingleOne();

    // 创建公有静态返回
    public static SingleOne getInstance(){
        return instance;
    }
}

通过双重校验锁实现懒汉式保线程安全

代码语言:javascript
复制
// 双重校验锁实现
class Singleton{
    //私有化
    private Singleton(){

    }
    // 私有化对象
    private volatile static Singleton instance;

    // 返回方法
    public static Singleton getInstance(){
        if (instance==null){
            // 加锁
            synchronized (Singleton.class){
                if (instance==null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

}

1.为什么使用volatile修饰了singleton引用还用synchronized? volatile 只保证了共享变量 singleton 的可见性,但是 singleton = new Singleton(); 这个操作不是原子的,如上面提到可以分为三步: 步骤1:在堆内存申请一块内存空间; 步骤2:初始化申请好的内存空间; 步骤3:将内存空间的地址赋值给 singleton; 所以singleton = new Singleton(); 是一个由三步操作组成的复合操作,多线程环境下A 线程执行了第一步、第二步之后发生线程切换,B 线程开始执行第一步、第二步、第三步(因为A 线程singleton 是还没有赋值的),所以为了保障这三个步骤不可中断,可以使用synchronized 在这段代码块上加锁。 2.第一次检查singleton为空后为什么内部还进行第二次检查 A 线程进行判空检查之后开始执行synchronized代码块时发生线程切换(线程切换可能发生在任何时候),B 线程也进行判空检查,B线程检查 singleton == null 结果为true,也开始执行synchronized代码块,虽然synchronized 会让二个线程串行执行,如果synchronized代码块内部不进行二次判空检查,singleton 可能会初始化二次。 3.volatile 除了内存可见性,还有别的作用吗? volatile 修饰的变量除了可见性,还能防止指令重排序。 指令重排序 是编译器和处理器为了优化程序执行的性能而对指令序列进行重排的一种手段。现象就是CPU 执行指令的顺序可能和程序代码的顺序不一致,例如 a = 1; b = 2; 可能 CPU 先执行b=2; 后执行a=1; singleton = new Singleton(); 由三步操作组合而成,如果不使用volatile 修饰,可能发生指令重排序。步骤3 在步骤2 之前执行,singleton 引用的是还没有被初始化的内存空间,别的线程调用单例的方法就会引发未被初始化的错误。

多线程在执行过程中,数据的不可见性,原子性,以及重排序所引起的指令有序性 三个问题基本是多线程并发问题的三个重要特性,也就是我们常说的:

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2018/01/19 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、什么叫线程安全?
  • 二、前言:线程安全的问题
  • 三、Java内存模型(JMM)数据可见性问题、指令重排序、内存屏障
    • 1、简单理解CPU
      • 2、内存屏障(memory barriers)
        • 3、Java的内存模型JMM(Java Memory Medel)
          • 4、重排序
          • 四、导致并发线程安全的原因总结
            • 1、原子性
              • 2、重排序
                • 3、可见性
                • 五、上面示例分析
                • 五、保证线程安全机制有哪些?
                • 六、保证线程安全机制案例
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档