美文网首页
Java并发系列(一)——线程安全性

Java并发系列(一)——线程安全性

作者: 维特or卡顿 | 来源:发表于2019-08-19 00:03 被阅读0次

    前言

    在构建并发程序时,必须正确的使用线程和锁。编写线程安全的代码的核心在于要对状态访问操作进行管理,特别是对共享可变状态的访问。
    共享:变量可以由多个线程同时访问
    可变:变量的值在其生命周期内可以发生变化

    初探

    一个对象是否需要是线程安全的,取决于它是否会被多个线程访问。注意,这指得是程序中访问对象的方式,而不是对象要实现的功能

    线程安全的对象

    有三种方式可以让一个可变状态的的变量成为一个线程安全的对象:

    1. 不在线程之间共享该变量
    2. 将这个变量修改为不可变的变量
    3. 在访问变量时使用同步

    同步机制

    采用同步机制来协同对对象可变状态的访问可以让该对象变的线程安java中的提供的实现同步机制的办法包括:

    • synchronizend,一种独占锁的方式;
    • volatile类型的变量;
    • 显示锁(Explicit Lock);
    • 原子变量;

    线程安全性

    当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调试代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
    在定义中,最核心的概念就是正确性。其含义为:<u>类的行为与其规范的完全一致</u>
    让我们看一个不正确的典范,为售票处设计一个买票系统,五个窗口卖五张票:

    public class Ticket extends Thread {
        private int count = 5;
        @Override
        public void run() {
            super.run();
            count--;
            System.out.println(this.currentThread().getName() + ":" + count);
        }
    }
    
    public class Sale {
        public static void main(String[] args){
            Ticket ticket = new Ticket();
            Thread a = new Thread(ticket,"A");
            Thread b = new Thread(ticket,"B");
            Thread c = new Thread(ticket,"C");
            Thread d = new Thread(ticket,"D");
            Thread e = new Thread(ticket,"E");
            a.start();
            b.start();
            c.start();
            d.start();
            e.start();
        }
    }
    

    这段代码的输出结果是不确定的,输出的结果可能是下面这样的:

    image
    这就是一个不安全的典范,这段代码所产生的行为跟我们预期设计的完全不一致。
    我们稍微改变一下需求,将五个窗口卖五张票改为五个窗口各卖五张票。修改代码为:
    public class TicketNoState extends Thread {
        //private int count = 5;
        @Override
        public void run() {
            super.run();
            int count = 5;
            do {
                count--;
                System.out.println(this.currentThread().getName() + ":" + count);
            } while (count > 0);
        }
    }
    

    这段代码不会产生前面代码所犯的错误,TicketNoState是无状态的:

    • 不包含任何域
    • 不包含任何对其他类中域的引用
      计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问TicketNoState的线程不会影响另一个线程的计算过程和结果,因为这两个线程并没有共享状态,就好像他们在访问不同的实例。

    <u>无状态对象一定是线程安全的</u>

    原子性

    一个原子操作是一个不能分割的整体,没有其他线程能够中断或检查正处于原子操作中的变量。原子操作在没有锁的情况下可以做到线程安全。
    回忆上文中那个不安全的卖票代码中的count--操作,紧凑的语法使得它看上去只是一个操作,但它确实非原子的。实际上它包含了三个独立的操作:读取count的值,将其减1,然后将计算结果写入count。这是一个“读取-计算-写入”的过程,上文中错误的输出就是由这段操作产生的。
    使用原子类可以实现对count的原子操作:

    public class Ticket extends Thread {
        private AtomicInteger count = new AtomicInteger(5);
        @Override
        public void run() {
            super.run();
            System.out.println(this.currentThread().getName() + "卖出去了一张:" +                 count.getAndDecrement());
        }
    }
    

    注意:原子类在具有有逻辑性的情况下也是不安全的。因为方法之间的调用不是原子的。
    在原子操作中,对于访问同一个状态的所有操作来说,这个操作是以原子方式执行的操作。假如存在两个操作A和B,如果从执行A的线程来看,当另一个线程执行B的时候,要么将B全部执行完,要么完全不执行B,那么A和B彼此是原子的。与原子操作对应的就是复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性的操作。

    竞态条件

    程序的结果取决于运气!

    如果程序运行顺序的改变会影响最终结果,这就是一个竞态条件
    也就是说当某个计算的正确性取决于多个线程的交替执行序时,那么就会发生竞态条件。

    要想理解这个定义,首先要知道程序运行不一定是线性的。不按顺序执行的典范还是多线程程序。例如上文中的代码创建的五个线程,虽然它们是依次启动,但他们内部的代码谁先执行就不得而知了。
    如果一段程序运行多次的结果不一致,那这就可能是竞态条件的体现。最常见的竞态条件类型是“先检查后执行”,通过观测一个结果来决定程序下一步的走向,而这个结果可能是已经失效了的。例如上文中五个窗口售卖五张车票的代码,多次执行会有不同结果。
    另一个常见的类型就是延迟初始化中的竞态条件,观察下面实现单例的代码:

    public class LazyInit {
        private static LazyInit mLazyInit = null;
        public static LazyInit getLazyInit() {
            if (mLazyInit == null) {
                mLazyInit = new LazyInit();
            }
            return mLazyInit;
        }
    }
    

    LazyInit中就包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getLazyInit,当A先进入是,A看到mLazyInit为空,变去新建一个实例。当A尚未创建成功时,此时mLazyInit依旧为空,而此时,如果B进入,发现mLazyInit为空,也会去进行初始化的操作。这样getLazyInit就会返回两个不同的实例。

    加锁机制

    <u>加锁机制可以实现操作的原子性。</u>
    同步代码块:Java提供了一种内置的锁机制来支持原子性。它包含两个部分:
    一个作为锁的对象引用 一个由这个锁保护的代码块
    例如:

    synchronized (lock){
        //被保护的代码块
    }
    
    内置锁

    每个Java对象都可以用来作为一个同步锁,即内置锁(监视器锁)。线程在进入同步代码块之前会自动获取锁,并且在退出的时候释放锁。获得锁唯一的方法就是进入由这个锁保护的同步代码块或者方法。
    用关键字synchronized修饰整方法就是一种横跨整个方法的同步代码块

    synchronized public static LazyInit getLazyInit() {
            if (mLazyInit == null) {
                mLazyInit = new LazyInit();
            }
            return mLazyInit;
        }
    

    其中的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁:

    synchronized (LazyInit.class){
        //被保护的代码块
    }
    

    <u>Java的内置锁是一种互斥锁,同一时间内最多只有有一个线程能持有这种锁,每次只能由一个线程执行锁保护起来的代码块,于是这些代码块得以以原子的方式执行</u>。

    重入

    内置锁是可以重如的:当A线程请求一个由B线程持有的锁时,A线程会被阻塞,如果B线程视图获得一个已经由它自己持有的锁,请求则会成功,不会发生阻塞。(自己可以再次获取自己持有的锁)
    例如:当存在类继承关系,子类可以同过锁可重如的特性调用父类的同步方法:

    public class Human {
        public synchronized void method(){
    
        }
    }
    
    public class Student  extends Human {
        @Override
        public synchronized void method() {
            //调用父类的同步方法
            super.method();
        }
    }
    

    需要注意的是:简单的且粗粒度的使用同步方法确实能确保线程安全性,但付出的代价却极高————被同步的方法每次只能由一个线程执行,无法同时处理多个请求。

    总结

    该文为Java并发系列博客之一,为基础篇。该系列博客是我学习过程中的总结,希望持续关注Java并发系列博客,积极讨论,一起学习成长。

    相关文章

      网友评论

          本文标题:Java并发系列(一)——线程安全性

          本文链接:https://www.haomeiwen.com/subject/xekjsctx.html