1

上头一直在说以线程为基础的并发编程的好处了,什么提高处理器利用率啦,简化编程模型啦。但是砖家们还是认为并发编程是程序开发中最不可捉摸、最诡异、最扯犊子、最麻烦、最恶心、最心烦、最容易出错、最不符合社会主义核心价值观的一个部分~ 造成这么多最的原因其实很简单:进程中的各种资源,比如内存和I/O,在代码里以变量的形式展现,而某些变量在多线程间是共享、可变的,共享意味着这个变量可以被多个线程同时访问,可变意味着变量的值可能被访问它的线程修改。围绕这些共享、可变的变量形成了并发编程的三大杀手:安全性、活跃性、性能,下边我们来详细唠叨这些风险~

共享变量的含义

并不是所有内存变量都可以被多个线程共享,在一个线程调用一个方法的时候,会在栈内存上为局部变量以及方法参数申请一些内存,在方法调用结束的时候,这些内存便被释放。不同线程调用同一个方法都会为局部变量和方法参数拷贝一个副本(如果你忘了,需要重新学习一下方法的调用过程),所以这个栈内存是线程私有的,也就是说局部变量和方法参数是不可以共享的。但是对象或者数组是在堆内存上创建的,堆内存是所有线程都可以访问的,所以包括成员变量、静态变量和数组元素是可共享的,我们之后讨论的就是这些可以被共享的变量对并发编程造成的风险~ 如果不强调的话,我们下边所说的变量都代表成员变量、静态变量或者数组元素。

安全性

原子性操作、内存可见性和指令重排序是构成线程安全性的三个主题,下边我们详细看哈~

原子性操作

我们先拿一个例子开场:

public class Increment {

    private int i;

    public void increase() {
        i++;
    }

    public int getI() {
        return i;
    }

    public static void test(int threadNum, int loopTimes) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[threadNum];

        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < loopTimes; i++) {
                        increment.increase();
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (Thread t : threads) {  //main线程等待其他线程都执行完成
            try {
                t.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(threadNum + "个线程,循环" + loopTimes + "次结果:" + increment.getI());
    }

    public static void main(String[] args) {
        test(20, 1);
        test(20, 10);
        test(20, 100);
        test(20, 1000);
        test(20, 10000);
        test(20, 100000);
    }
}

其中,increase方法的作用是给成员变量i增1,test方法接受两个参数,一个是线程的数量,一个是循环的次数,每个线程中都有一个将成员变量i增1给定循环次数的任务,在所有线程的任务都完成之后,输出成员变量i的值,如果没有什么问题的话,程序执行完成后成员变量i的值都是threadNum*loopTimes。大家看一下执行结果:

20个线程,循环1次结果:20
20个线程,循环10次结果:200
20个线程,循环100次结果:2000
20个线程,循环1000次结果:19926
20个线程,循环10000次结果:119903
20个线程,循环100000次结果:1864988

咦,貌似有点儿不对劲唉~再次执行一遍的结果:

20个线程,循环1次结果:20
20个线程,循环10次结果:200
20个线程,循环100次结果:2000
20个线程,循环1000次结果:19502
20个线程,循环10000次结果:100157
20个线程,循环100000次结果:1833170

这就更令人奇怪了~~ 当循环次数增加时,执行结果与我们预期不一致,而且每次执行貌似都是不一样的结果,这个是个什么鬼?

答:这个就是多线程的非原子性操作导致的一个不确定结果。

啥叫个原子性操作呢?就是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。java中自带了一些原子性操作,比如给一个非long、double基本数据类型变量或者引用的赋值或者读取操作。

为什么强调非long、double类型的变量?我们稍后看哈~

那i++这个操作不是一个原子性操作么?

答:还真不是,这个操作其实相当于执行了i = i + 1,也就是三个原子性操作:

  1. 读取变量i的值
  2. 将变量i的值加1
  3. 将结果写入i变量中

由于线程是基于处理器分配的时间片执行的,在这个过程中,这三个步骤可能让多个线程交叉执行,为简化过程,我们以两个线程交叉执行为例,看下图:

这个图的意思就是:
图片描述

  1. 线程1执行increase方法先读取变量i的值,发现是5,此时切换到线程2执行increase方法读取变量i的值,发现也是5。
  2. 线程1执行将变量i的值加1的操作,得到结果是6,线程二也执行这个操作。
  3. 线程1将结果赋值给变量i,线程2也将结果赋值给变量i。

在这两个线程都执行了一次increase方法之后,最后的结果竟然是变量i从5变到了6,而不是我们想象中的7。。。

另外,由于CPU的速度非常快,这种交叉执行在执行次数较低的时候体现的并不明显,但是在执行次数多的时候就十分明显了,从我们上边测试的结果上就能看出。

在真实编程环境中,我们往往需要某些涉及共享、可变变量的一系列操作具有原子性,我们可以从下边三个角度来保证这些操作具有原子性。

从共享性解决

如果一个变量变得不可以被多线程共享,不就可以随便访问了呗哈哈,大致有下面这么两种改进方案。

尽量使用局部变量解决问题

因为方法中的局部变量(包括方法参数和方法体中创建的变量)是线程私有的,所以无论多少线程调用某个不涉及共享变量的方法都是安全的。所以如果能将问题转换为使用局部变量解决问题而不是共享变量解决,那将是极好的哈~。不过我貌似想不出什么案例来说明一下,等想到了再说哈,各位想到了也可以告诉我哈。

使用ThreadLocal类

为了维护一些线程内可以共享的数据,java提出了一个ThreadLocal类,它提供了下边这些方法:

public class ThreadLocal<T> {

    protected T initialValue() {
        return null;
    }

    public void set(T value) {
        ... 
    }

    public T get() {
        ... 
    }

    public void remove() {
         ...
     }
}

其中,类型参数T就代表了在同一个线程中共享数据的类型,它的各个方法的含义是:

  • T initialValue():当某个线程初次调用get方法时,就会调用initialValue方法来获取初始值。
  • void set(T value):调用当前线程将指定的value参数与该线程建立一对一关系(会覆盖initialValue的值),以便后续get方法获取该值。
  • T get():获取与当前线程建立一对一关系的值。
  • void remove():将与当前线程建立一对一关系的值移除。

我们可以在同一个线程里的任何代码处存取该类型的值:

public class ThreadLocalDemo {

    public static ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<String>(){
        @Override
        protected String initialValue() {
            return "调用initialValue方法初始化的值";
        }
    };

    public static void main(String[] args) {
        ThreadLocalDemo.THREAD_LOCAL.set("与main线程关联的字符串");
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("t1线程从ThreadLocal中获取的值:" + ThreadLocalDemo.THREAD_LOCAL.get());
                ThreadLocalDemo.THREAD_LOCAL.set("与t1线程关联的字符串");
                System.out.println("t1线程再次从ThreadLocal中获取的值:" + ThreadLocalDemo.THREAD_LOCAL.get());
            }
        }, "t1").start();

        System.out.println("main线程从ThreadLocal中获取的值:" + ThreadLocalDemo.THREAD_LOCAL.get());
    }
}

执行结果是:

main线程从ThreadLocal中获取的值:与main线程关联的字符串
t1线程从ThreadLocal中获取的值:调用initialValue方法初始化的值
t1线程再次从ThreadLocal中获取的值:与t1线程关联的字符串

从这个执行结果我们也可以看出来,不同线程操作同一个 ThreadLocal 对象执行各种操作而不会影响其他线程里的值。这一点非常有用,比如对于一个网络程序,通常每一个请求都分配一个线程去处理,可以在ThreadLocal里记录一下这个请求对应的用户信息,比如用户名,登录失效时间什么的,这样就很有用了。

虽然ThreadLocal很有用,但是它作为一种线程级别的全局变量,如果某些代码依赖它的话,会造成耦合,从而影响了代码的可重用性,所以设计的时候还是要权衡一下子滴。

从可变性解决

如果一个变量可以被共享,但是它自打被创建之后就不能被修改,那么随意哪个线程去访问都可以哈,反正又不能改变它的值,随便读啦~

再强调一遍,我们写的程序可能不仅我们自己会用,所以我们不能靠猜、靠直觉、靠信任其他使用我们写的代码的客户端程序猿,所以如果我们想通过让对象不可变的方式来保证线程安全,那就把该变量声明为 final 的吧 :

public class FinalDemo {
    private final int finalField;

    public FinalDemo(int finalField) {
        this.finalField = finalField;
    }
}

然后就可以随便在多线程间共享finalField这个变量喽~

加锁解决

锁的概念

如果我们的需求确实是需要共享并且可变的变量,又想让某些关于这个变量的操作是原子性的,还是以上边的increase方法为例,我们现在面临的困境是increase方法其实是由下边3个原子性操作累积起来的一个操作:

  1. 读变量i;
  2. 运算;
  3. 写变量i;

针对同一个变量i,不同线程可能交叉执行上边的三个步骤,导致两个线程读到同样的变量i的值,从而导致结果比预期的小。为了让increase方法里的操作具有原子性,也就是在一个线程执行这一系列操作的同时禁止其他线程执行这些操作,java提出了锁的概念。

我们拿上厕所做一个例子,比如我们上厕所需要这几步:

  1. 脱裤子
  2. 干正事儿
  3. 擦屁股
  4. 提裤子

上厕所的时候必须把这些步骤都执行完了,才能圆满的完成上厕所这个事儿,要不然执行到擦屁股环节被别人赶出来岂不是贼尴尬😅,所以为了能安全的完成上厕所这个事儿,我们不得不在进入厕所之后,就拿一把锁把厕所门给锁了,等提完裤子走出厕所的时候再把锁给打开,让其他人来上厕所。

同步代码块

java语言里把锁给做了个抽象,任何一个对象都可以作为一个锁,也称为内置锁,某个线程在进入某个代码块的时候去获取一个锁,在退出该代码块的时候把锁给释放掉,我们来修改一下Increment的代码:

public class Increment {

    private int i;

    private Object lock = new Object();

    public void increase() {
        synchronized (lock) {
            i++;
        }
    }

    public int getI() {
        synchronized (lock) {
            return i;
        }
    }

    public static void test(int threadNum, int loopTimes) {
    // ... 为节省篇幅,省略此处代码,与上边的一样
    }

    public static void main(String[] args) {
        test(20, 1);
        test(20, 10);
        test(20, 100);
        test(20, 1000);
        test(20, 10000);
        test(20, 100000);
    }
}

对i++加锁之后的代码执行结果是:

20个线程,循环1次结果:20
20个线程,循环10次结果:200
20个线程,循环100次结果:2000
20个线程,循环1000次结果:20000
20个线程,循环10000次结果:200000
20个线程,循环100000次结果:2000000

哈哈,这回就符合预期了,如果你不信可以多执行几遍试试。

我们再回过头来看这个加锁的语法:

synchronized (锁对象) {
    需要保持原子性的一系列代码
}

如果一个线程获取某个锁之后,就相当于把厕所门儿给锁上了,其他的线程就不能获取该锁了,进不去厕所只能干等着,也就是这些线程处于一种阻塞状态,直到已经获取锁的线程把该锁给释放掉,也就是把厕所门再打开,某个线程就可以再次获得锁了。这样线程们按照获取锁的顺序执行的方式也叫做同步执行(英文名就是synchronized),这个被锁保护的代码块也叫做同步代码块,我们也会说这段代码被这个锁保护。由于如果线程没有获得锁就会阻塞在同步代码块这,所以我们需要格外注意的是,在同步代码块中的代码要尽量的短,不要把不需要同步的代码也加入到同步代码块,在同步代码块中千万不要执行特别耗时或者可能发生阻塞的一些操作,比如I/O操作啥的。

为什么一个对象就可以当作一个锁呢?我们知道一个对象会占据一些内存,这些内存地址可是唯一的,也就是说两个对象不能占用相同的内存。真实的对象在内存中的表示其实有对象头和数据区组成的,数据区就是我们声明的各种字段占用的内存部分,而对象头里存储了一系列的有用信息,其中就有几个位代表锁信息,也就是这个对象有没有作为某个线程的锁的信息。详细情况我们会在JVM里详细说明,现在大家看个乐呵,了解用一个对象作为锁是有底层依据的哈~

锁的重入

我们前边说过,当一个线程请求获得已经被其他线程获得的锁的时候,它就会被阻塞,但是如果一个线程请求一个它已经获得的锁,那么这个请求就会成功。

public class SynchronizedDemo {

    private Object lock = new Object();

    public void m1() {
        synchronized (lock) {
            System.out.println("这是第一个方法");
            m2();
        }
    }

    public void m2() {
        synchronized (lock) {
            System.out.println("这是第二个方法");
        }
    }

    public static void main(String[] args) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        synchronizedDemo.m1();
    }
}

执行结果是:

这是第一个方法
这是第二个方法

也就是说只要一个线程持有了某个锁,那么它就可以进入任何被这个锁保护的代码块。

小贴士:

这样的重入锁实现起来也简单,可以给每个锁关联一个持有的线程和获取锁的次数,初始状态下锁的计数值是0,也就是没有被任何线程持有锁,当某个线程获取这个锁的时候,计数值为1,如果继续获取该锁,那么计数值继续递增,每次退出一个同步代码块时,计数值递减,直到递减到0为止。

同步方法

我们前边说为了创建一个同步代码块,必须显式的指定一个对象作为锁,有时候我们想把整个方法的操作都写入同步代码块,就像我们上边说过的increase方法,这种情况下其实有个偷懒的办法,因为我们的程序中默默的藏着某些对象~

  • 对于成员方法来说,我们可以直接用this作为锁。
  • 对于静态方法来说,我们可以直接用Class对象作为锁(Class对象可以直接在任何地方访问,如果不知道的话需要重新学一下反射了亲)。

就像这样:

public class Increment {

    private int i;

    public void increase() {
        synchronized (this) {   //使用this作为锁
            i++;
        }
    }

    public static void anotherStaticMethod() {
        synchronized (Increment.class) {   //使用Class对象作为锁
            // 此处填写需要同步的代码块
        }
    }
}

为了简便起见,设计java的大叔们规定整个方法的操作都需要被同步,而且使用this作为锁的成员方法,使用Class对象作为锁的静态方法,就可以被简写成这样:

public class Increment {

    private int i;

    public synchronized increase() {   //使用this作为锁
        i++;
    }

    public synchronized static void anotherStaticMethod() {   //使用Class对象作为锁
        // 此处填写需要同步的代码块
    }
}

再写一遍通用格式,大家长长记性:

public synchronized 返回类型 方法名(参数列表) {
    需要被同步执行的代码
}

public synchronized static 返回类型 方法名(参数列表) {
    需要被同步执行的代码
}

上述的两种方法也被称为同步方法,也就是说整个方法都需要被同步执行,而且使用的锁是this对象或者Class对象。

注意,同步方法只不过是同步代码块的另一种写法,没什么稀奇的~。

总结

  1. 共享、可变的变量形成了并发编程的三大杀手:安全性、活跃性、性能,本章详细讨论安全性问题。
  2. 本文中的共享变量指的是在堆内存上创建的对象或者数组,包括成员变量、静态变量和数组元素。
  3. 安全性问题包括三个方面,原子性操作、内存可见性和指令重排序,本篇文章主要对原子性操作进行详细讨论。
  4. 原子性操作就是一个或某几个操作只能在一个线程执行完之后,另一个线程才能开始执行该操作,也就是说这些操作是不可分割的,线程不能在这些操作上交替执行。
  5. 为了保证某些操作的原子性,提出了下边几种解决方案:
  • 尽量使用局部变量解决问题
  • 使用ThreadLocal类解决问题
  • 从共享性解决,在编程时,最好使用下边这两种方案解决问题:
  • 从可变性解决,最好让某个变量在程序运行过程中不可变,把它使用final修饰。
  • 加锁解决
  1. 任何一个对象都可以作为一个锁,也称为内置锁。某个线程在进入某个同步代码块的时候去获取一个锁,在退出该代码块的时候把锁给释放掉。
  2. 锁的重入是指只要一个线程持有了某个锁,那么它就可以进入任何被这个锁保护的代码块。
  3. 同步方法是一种比较特殊的同步代码块,对于成员方法来讲,使用this作为锁对象,对于静态方法来说,使用Class对象作为锁对象。

wayen
203 声望19 粉丝

后端工程师