资讯专栏INFORMATION COLUMN

Java并发编程之原子性操作

instein / 3187人阅读

摘要:将与当前线程建立一对一关系的值移除。为了让方法里的操作具有原子性,也就是在一个线程执行这一系列操作的同时禁止其他线程执行这些操作,提出了锁的概念。

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

读取变量i的值

将变量i的值加1

将结果写入i变量中

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

这个图的意思就是:

线程1执行increase方法先读取变量i的值,发现是5,此时切换到线程2执行increase方法读取变量i的值,发现也是5。

线程1执行将变量i的值加1的操作,得到结果是6,线程二也执行这个操作。

线程1将结果赋值给变量i,线程2也将结果赋值给变量i。

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

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

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

从共享性解决

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

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

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

使用ThreadLocal类

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

public class ThreadLocal {

    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 THREAD_LOCAL = new ThreadLocal(){
        @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个原子性操作累积起来的一个操作:

读变量i;

运算;

写变量i;

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

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

脱裤子

干正事儿

擦屁股

提裤子

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

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/74150.html

相关文章

  • Java并发编程的艺术】第二章读书笔记原子操作

    摘要:前言今天的笔记来了解一下原子操作以及中如何实现原子操作。概念原子本意是不能被进一步分割的最小粒子,而原子操作意为不可被中断的一个或一系列操作。处理器实现原子操作处理器会保证基本内存操作的原子性。 showImg(https://segmentfault.com/img/bVVIRA?w=1242&h=536); 前言 今天的笔记来了解一下原子操作以及Java中如何实现原子操作。 概念 ...

    olle 评论0 收藏0
  • Java 并发编程系列带你了解多线程

    摘要:的内置锁是一种互斥锁,意味着最多只有一个线程能持有这种锁。使用方式如下使用显示锁之前,解决多线程共享对象访问的机制只有和。后面会陆续的补充并发编程系列的文章。 早期的计算机不包含操作系统,它们从头到尾执行一个程序,这个程序可以访问计算机中的所有资源。在这种情况下,每次都只能运行一个程序,对于昂贵的计算机资源来说是一种严重的浪费。 操作系统出现后,计算机可以运行多个程序,不同的程序在单独...

    Elle 评论0 收藏0
  • 来,了解一下Java内存模型(JMM)

    摘要:因为管理人员是了解手下的人员以及自己负责的事情的。处理器优化和指令重排上面提到在在和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。有没有发现,缓存一致性问题其实就是可见性问题。 网上有很多关于Java内存模型的文章,在《深入理解Java虚拟机》和《Java并发编程的艺术》等书中也都有关于这个知识点的介绍。但是,很多人读完之后还是搞不清楚,甚至有的人说自己更懵了。本文,就来整体的...

    kviccn 评论0 收藏0
  • 来,了解一下Java内存模型(JMM)

    摘要:因为管理人员是了解手下的人员以及自己负责的事情的。处理器优化和指令重排上面提到在在和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。有没有发现,缓存一致性问题其实就是可见性问题。 网上有很多关于Java内存模型的文章,在《深入理解Java虚拟机》和《Java并发编程的艺术》等书中也都有关于这个知识点的介绍。但是,很多人读完之后还是搞不清楚,甚至有的人说自己更懵了。本文,就来整体的...

    eccozhou 评论0 收藏0
  • 来,了解一下Java内存模型(JMM)

    摘要:因为管理人员是了解手下的人员以及自己负责的事情的。处理器优化和指令重排上面提到在在和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。有没有发现,缓存一致性问题其实就是可见性问题。 网上有很多关于Java内存模型的文章,在《深入理解Java虚拟机》和《Java并发编程的艺术》等书中也都有关于这个知识点的介绍。但是,很多人读完之后还是搞不清楚,甚至有的人说自己更懵了。本文,就来整体的...

    xfee 评论0 收藏0

发表评论

0条评论

instein

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<