Java多线程编程(2)- 线程安全

Java多线程编程(2)- 线程安全,第1张

1 竞态

       竞态指计算结果的正确性依赖相对时间顺序和线程的交错,通俗的说就是计算结果与时间有关,对于一个同样的输入,有时候结果正确,有时候结果不正确。竞态不一定会导致结果错误,只是说有这种导致结果出错的可能性。竞态往往伴随着读取脏数据问题,或是丢失更新问题。

2 竞态的模式及竞态产生的条件

竞态有两种模式:

  • read-modify-write(读-改-写)

      即读取一个共享变量的值(read),然后根据值做一些计算(modify),最后更新该变量的值(write)。例如sequence++就是如此,过程指令如下:

  1. 从内存中将squence的值读取到寄存器r1中
  2. r1的值加1
  3. 将r1的值更新到sequence变量的内存空间中

       在多线程环境下,某个线程执行完1后,可能其他线程已经更新了sequence的值,但是该线程却仍然使用r1未更新的值去 *** 作2,3指令,造成丢失更新和脏读。

  • check-then-act(检测而后行动)

       以下面这段代码为例:

public short nextSequence() 
{
    if (sequence >= SEQ_UPPER_LIMIT) {    //步骤1
        sequence = 0;          //步骤2.1
    } else { 
        sequence++;                  //步骤2.2
    }
    return sequence;
}

  检测而后行动指读取某个共享变量的值,根据该值做一些判断,然后根据该判断结果去条件执行一些 *** 作。

    在多线程环境下,可能当某个线程执行完步骤1后,其它线程更新了sequence的值,此时该线程仍然根据之前的判断去做一些 *** 作,也会造成丢失数据更新和脏读。

竞态产生的条件分析:

      设A和B是并发访问共享变量V的两个 *** 作,这两个 *** 作并非都是读 *** 作。如果一个线程在执行A期间,另外一个线程正在执行B,那么无论B是在读取V还是更新V都会导致竞态。从这个角度来看,竞态可以被看作访问(读取、更新)同一组共享变量的多个线程所执行的 *** 作相互交替而导致的干扰(读取脏数据)或冲突(丢失更新)的结果。而对于局部变量,由于不同的线程各自访问的都是各自的那一个局部变量,因此局部变量的使用不会导致竞态。

3 线程安全问题三要素

线程安全的核心问题表现为3个方面:原子性、可见性和有序性。

3.1 原子性(Atomicity)

       原子性的含义是指访问(读、写)某个共享变量的 *** 作从其执行线程以外的任何线程来看,该 *** 作要么已经执行结束,要么尚未发生,即其他线程不会看到该 *** 作执行了部分的中间结果。访问同一组共享变量的原子 *** 作是不能够交错的,因此,原子性的 *** 作消除了导致竞态的可能性。

理解原子性语义有两点需要注意:

  • 原子 *** 作时针对访问共享变量的 *** 作而言的,也就是说,仅涉及局部变量访问的 *** 作是无所谓是否原子的,或者干脆把这一类 *** 作都看成原子 *** 作。
  • 原子 *** 作是从该 *** 作的执行线程以外的线程来描述的,也就是说它只有在多线程环境下才有意义。

Java中有两种方式实现原子性:

  • 一是使用锁(Lock)。锁具有排他性,即它能够保证一个共享变量在任意时刻只能被一个线程访问,这就排除了多个线程同一时刻访问同一个共享变量而导致干扰与冲突的可能,从而消除了竞态。
  • 二是利用处理器提供的专门CAS(Compare and Swap)指令。CAS指令实现原子性的方式与锁实现原子性的方式实质上是相同的,差别在于锁通常是在软件这一层实现的,而CAS是直接在硬件这一层实现的,它可以被看作硬件锁。

       Java语言中,除long和double型以外的任何类型的变量的写 *** 作都是原子 *** 作,即基础类型(包括byte、boolean、short、char、float和int)以及引用型变量。而long和double型的变量写 *** 作由于Java语言规范并不保证其具有原子性,但可以通过volatile关键字修饰long/double型变量,从而使对这两种类型变量的写 *** 作具有原子性。

volatile long value;//通过使用volatile关键字使对变量value的写 *** 作具有原子性

Userinfo userinfo1 = null;
Userinfo userinfo2 = new Userinfo(userid);
userinfo1 = userinfo2;//原子 *** 作

       需要注意的是,volatile关键字仅能保证变量写 *** 作的原子性,但并不能保证其他 *** 作如read-modify-write *** 作和check-then-act的原子性。

       另外,Java语言中对任何变量的读 *** 作都是原子 *** 作。“原子 *** 作+原子 *** 作”所得到的符合 *** 作并非原子 *** 作。

3.2 可见性(Visibility)

       可见性是指一个线程对共享变量更新的结果对于读取该共享变量的线程是否可见的问题。如果一个线程对某个共享变量进行更新后,后续访问该变量的线程可以读取到该更新结果,那么就称这个线程对该共享变量的更新对其他线程可见,否则就称这个线程对该共享变量的更新对其他线程不可见。多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据,而这可能导致程序出现所不期望的结果。

       Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。它遵循四个原则:

  1. 所有的变量都存储在主内存中。
  2. 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)。
  3. 线程对共享变量的所有 *** 作都必须在自己的工作内存中进行,不能直接从主内存中读写。
  4. 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量的传递需要通过主内存来完成。

因此:

  • 对于串行程序来说,可见性问题是不存在的,因为你在任何一个 *** 作步骤中修改了某个变量,在后续的步骤中读取这个变量的值时,读取的一定是修改后的新值。
  • 在并行程序中,如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。多核时代,每颗 CPU 都有自己的缓存,一个CPU缓存中的变量对另外一个CPU是不可见的。
  • 在单处理器的情况下,多线程的并发执行实际使通过时间片分配实现的。此时,虽然多个线程是运行在同一个处理器上,但由于在发生上下文切换时,一个线程对寄存器变量的修改会被该线程的上下文保存起来,这导致另一个线程无法看到该线程对这个变量的修改,因此,单处理器系统中实现的多线程编程也可能出现可见性问题。

       虽然一个处理器的高速缓存中的内容不能被另一个处理器直接读取,但一个处理器可以通过缓存一致性协议(cache coherence protocol)来读取其他处理器的高速缓存中的数据,并将读取的数据更新到该处理器的高速缓存中。这种一个处理器从其自身处理器缓存以外的其他存储部件中读取数据并将其更新到该处理器高速缓存的过程称为缓存同步。缓存同步使得一个处理器上运行的线程可以读取到另外一个处理器上运行线程对共享变量所作的更新,即保障了可见性。

       为了保障可见性,必须使一个处理器对共享变量的更新最终写入该处理器的高速缓存或主内存中(而非一直停留在写缓冲器中),这个过程称为冲刷(flush)处理器缓存。一个处理器在读取共享变量时,如果其他处理器在这之前已经更新了该变量,那么该处理器必须从其他处理器的高速缓存或主内存中对相应的变量进行缓存同步,这个过程称为刷新(refresh)处理器缓存。由此可见,可见性的保障是通过使更新共享变量的处理器执行冲刷处理器缓存动作,而读取共享变量的处理器执行刷新处理器缓存动作来实现的。

       Java语言中,有两种方法保证可见性。一种可以通过volatile关键字来保证可见性。volatile关键字主要有两个作用:

  • 一是提示JIT(Just-In-Time)编译器被该关键字修饰的变量可能被多个线程共享,以阻止JIT编译器做出可能导致程序运行不正常的优化。
  • 另一个作用是读取一个volatile修饰的变量会使相应的处理器执行刷新处理器缓存的动作,写入volatile修饰的变量会使相应的处理器执行冲刷处理器缓存的动作,从而保障了可见性。

       另一种还可以通过Synchronized来保障可见性。JMM(java内存模型)关于synchronized的两条规定:

  • 线程解锁前(退出synchronized代码块之前),必须把共享变量的最新值刷新到主内存中,也就是说线程退出synchronized代码块值后,主内存中保存的共享变量的值已经是最新的了。
  • 线程加锁时(进入synchronized代码块之后),将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(注意:加锁与解锁需要是同一把锁)。
  • 两者结合:线程解锁前对共享变量的修改在下次加锁时对其他线程可见。

 两种方式对比: 

  1. volatile不需要加锁,比synchronized更轻量级,不会阻塞线程。
  2. 从内存可见性角度讲,volatile读 *** 作=进入synchronized代码块(加锁),volatile写 *** 作=退出synchronized代码块(解锁)。
  3. synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,不能保证原子性。
     

另外,在Java多线程中,有两点值得注意:

  • 父线程在启动子线程之前对共享变量的更新对子线程来说是可见的。
  • 一个线程终止后该线程对共享变量的更新对于调用该线程join方法的线程而言是可见的。 
package com.example.demo.thread;

import java.util.concurrent.TimeUnit;


public class ThreadVisibility {

    private static int data = 0;//共享变量

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(){
            @Override
            public void run(){
                try {
                    TimeUnit.MILLISECONDS.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("child thread1 print data= " + data + "  ,expect data = 1");
            }
        };

        data = 1;//启动前更新变量
        thread1.start();
        TimeUnit.MILLISECONDS.sleep(100);
        
        Thread thread2 = new Thread(){
            @Override
            public void run(){
                try {
                    TimeUnit.MILLISECONDS.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                data = 3;
            }
        };

        thread2.start();
        try{
            thread2.join();//等待thread2结束
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("parent print data after thread2 finished, data="+ data + "  ,expect data = 3");
    }
}

3.3 有序性 (Ordering)

       有序性指在同一线程中的指令应该按顺序执行,而多线程中的指令未必按顺序执行。在Java内存模型中,允许编译器和处理器对指令进行重排序。重排序是对内存访问有关的 *** 作所做的一种优化,它可以不影响单线程程序正确性的情况下提升程序的性能,但它可能对多线程程序的正确性产生影响,即可能导致线程安全问题。与可见性问题类似,重排序也不是必然发生的。

       重排序分为两类:指令重排序与存储子系统重排序。

  • 指令重排序:表现在程序顺序与源代码顺序不一致或者执行顺序与程序顺序不一致,也就是源代码中指定的内存访问顺序与得到的字节码顺序不一样 或者 字节码顺序与实际的执行顺序不一样。既然产生了不一样,那么问题肯定是出在连接这三个过程的中间件上面。Java平台包括两种编译器:静态编译器(javac)和动态编译器(JIT:just in time)。静态编译器是将.java文件编译成.class文件(二进制文件),之后便可以解释执行。动态编译器是将.class文件编译成机器码,之后再由jvm运行,它是在Java程序运行过程中介入的。JIT主要是做性能上面的优化,如热点代码编译成本地代码,加速调用。Javac基本不会调整指令顺序,调整指令顺序的大多出在JIT优化上。
  • 存储子系统重排序:表现在感知顺序与执行顺序不一样。存储子系统:简单理解就是主存与寄存器之间的高速缓存,细一点的话可以加上写缓冲器(提高写主存的效率)。从处理器的角度来说,读内存 *** 作是从指定的RAM地址加载数据(通过高速缓存加载)到寄存器,也就所谓的load *** 作。写内存将数据存储到指定地址的RAM中,也就是所谓的store *** 作。假设我们两个内存访问 *** 作都是严格按照程序顺序执行的,即不发生指令重排的情况,在存储子系统的作用下也会造成其他处理器(线程) 感知到 这两个内存访问 *** 作的顺序不一样。那么,这两个 *** 作可以有四种:其实就是load *** 作和store *** 作的排列组合。
      保证有序性的解决方法:

       在Java里面,可以通过volatile关键字来保证一定的“有序性”。当然可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。而volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性,也就是说:当程序执行到volatile变量的读 *** 作或者写 *** 作时,在其前面的 *** 作的更改肯定全部已经进行,且结果已经对后面的 *** 作可见;在其后面的 *** 作肯定还没有进行;在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

4 上下文切换

       当一个进程中的一个线程由于其时间片用完或其自身的原因被迫或主动暂停其运行时,另外一个线程可以被 *** 作系统选中占用处理器开始或者继续其运行,这种一个线程被暂停,另一个线程被选中或者继续运行的过程就叫线程上下文切换。线程被剥夺处理器的使用权而被暂停运行称为切出(Switch Out),线程被选中占用处理器开始或继续其运行称为切入(Switch In)。

       线程切入切出时需要恢复或保存相应线程的进度信息(如计算的中间结果、执行到了哪条指令等),这个进度信息就叫上下文(Context),通常包括通用寄存器的内容和程序计数器的内容。在切出时, *** 作系统需要将上下文保存在内存中,以便被切出的线程稍后占用处理器继续其运行时能够在此基础上进行,在切入时, *** 作系统需要从内存中加载被选中线程的上下文,以在之前运行的基础上继续进行。

       在Java线程来看,一个线程的生命周期状态在RUNNABLE状态与非RUNNABLE状态之间切换的过程就是一个上下文切换的过程。RUNNABLE转为非RUNNABLE,称为线程暂停,非RUNNABLE转为RUNNABLE称为线程唤醒。

       定性归总来说,上下文切换的开销包括直接开销和间接开销。

直接开销包括:

  • *** 作系统保存和恢复上下文所需的开销,这主要时处理器时间开销。
  • 线程调度器进行线程调度的开销(比如按照一定的规则决定哪个线程占用处理器运行)。

间接开销包括:

  • 处理器高速缓存重新加载的开销。
  • 上下文切换也可能导致整个一级高速缓存中的内容被冲刷,即一级高速缓存中的内容会被写入下一级高速缓存(如二级缓存)或者主内存中。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/874309.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-13
下一篇 2022-05-13

发表评论

登录后才能评论

评论列表(0条)

保存