java并发编程之原子性操作

线程风险

上头一直在说以线程为基础的并发编程的好处了,什么提高处理器利用率啦,简化编程模型啦。但是砖家们还是认为并发编程是程序开发中最不可捉摸、最诡异、最扯犊子、最麻烦、最恶心、最心烦、最容易出错、最不符合社会主义核心价值观的一个部分~ 造成这么多的原因其实很简单:进程中的各种资源,比如内存和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基本数据类型变量或者引用的赋值或者读取操作。

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

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方法之后,最后的结果竟然是变量i5变到了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. 提裤子

上厕所的时候必须把这些步骤都执行完了,才能圆满的完成上厕所这个事儿,要不然执行到擦屁股环节被别人赶出来岂不是贼尴尬

猜你喜欢

转载自blog.csdn.net/u013766342/article/details/80929474