Java并发编程实战-基础

最近读《Java并发编程实战》,颇多感悟,这本书相逢恨晚,和作者前言所述一样,这本书偏向和并发相关的设计级策略和模式,让人真正掌握并发程序的编写,这里记下一些感悟,供有需要朋友参考。

在现代CPU架构下,并发程序提高了系统资源利用率,优化了用户体验,简化了网络开发难度,但也是正是多线程错误使用引入了安全性问题,滥用多线程导致了性能问题。归根结底在于多线程引入了竞争,也就是多个线程共享了同一个可变变量,这导致了如下两个问题:

1.两个线程同时修改一个可变变量,变量的值是不确定的甚至非法的,即线程安全性问题

2.一个线程改变了值,另一个线程没有感知到这个变化,即内存可见性问题

 

线程安全性

线程安全性问题的本质在于引入了竞态条件。当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。最常见的竞态条件就是“先检查后执行”操作,此时基于一个可能失效的观察结果来做出判断或执行某个计算,这种情况一个线程在改变值的时候另一个线程可能设置了值也可能没有设置值,即变量的状态是不确定的(或者说可能失效的)。

如下代码,两个线程共享同一个MyObject1时,两个线程中同时调用Increment方法,我们想每个线程+1,结果为count=2,实际结果是什么呢?

/**
 * 演示线程竞态条件下安全性问题
 */
class MyObject1 {
    private volatile int count = 0;

    public int getCount() {
        return this.count;
    }

    public void Increment() {
        int v = this.count;
        v++;

        //强制线程切换
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        this.count = v;
    }
}

因为实际运行时线程切片是未知的,所以这里为了模拟线程切换加了强制切换的代码,实际运行结果count=1,这个实例实际上就是常见的count++同步示例的拆分。如下图所示,

这个过程中的问题在于线程2中观察到的count值不确定,线程2取值时可能取到的count=0也可能=1,所以才出现了上图中结果为1的情况;换个角度看,因为这种基于“先检查后执行”的过程中,可能检查完并没有来得及执行就切换到另一个线程中了,这时候再切换回来时检查的条件可能不再成立。

关于“先检查后执行“竞态条件导致的问题,更直观的实例如下:

    private static MyObject1 o;
    public MyObject1 GetSingleInstance(){
        if (o==null){
            App.o = new MyObject1();
        }

        return this.o;
    }

这是未同步的经典单实例方法,同样这里在线程1判断o==null后可能切换到线程2中判断o==null并创建了o,这时候再切回线程1,o==null的条件已经无效,但是还会创建新对象o,结果就出现创建两个对象o的情况

因此要想保证线程安全,就是要抑制竞态条件,让结果不再依赖线程执行时序。

内存可见性

上面是讨论的线程安全性问题,现在假设线程都是按照指定的顺序执行指定的逻辑,那么结果一定使按照我们期望的一样吗?如下代码,bStop作为一个标志变量来控制线程的停止,这个程序在所有的情况下Stop都能成功停止吗?

class MyThread2 extends Thread {
    private boolean bStop = false;

    void Stop(){
        System.out.println("MyThread2 end ");
        bStop = true;
    }

    @Override
    public void run() {
        while (!bStop) {
            System.out.println("Thread output!");
        }
    }
}

尽管这里看起来不存在安全性问题,但是实际上由于cpu工作方式(变量寄存器优化)和java多线程的内存模型(处理数据时,线程会把值从主存load到本地栈,完成操作后再save回去),在我们调用Stop时,尽管设置bStop为true,但是在run线程中可能无法观测到bStop的改变,导致了死循环。

从本质来说,这里其实还是一个安全性问题,因为我们可能观察到也可能观察不到变量的改变。这里boolean 的读写是原子操作,但是却存在可能没同步的问题,所以说变量加载锁在我们通常认识的原子改变作用之外,还有具有线程间内存同步的作用

 

下面来讨论实现线程安全类的方法,如下:

1.让变量不被多个线程共享

2.使用同步策略

3.设置变量只可读

 

变量不被多个线程共享

既然多线程的问题本质在于竞争,那么首先想到的就是去掉这种竞争,也就是每个线程维护自己的变量,这种技术称为线程封闭

线程封闭常用方法是:

1.Ad-hoc线程封闭,完全依赖程序保证变量不被多个线程共享

2.栈封闭,保证变量只在线程的栈上使用

3.线程局部变量ThreadLocal封闭,每个线程维持自己相关的变量值

线程封闭并不适合所有处理同步的问题,滥用会引入必要的耦合关系,一般主要用保存线程相关的特性(如Windows线程中每个线程相关的LastError)或可以从全局变量中拆分出的特性(如每个线程维护一个局部缓冲区)。这里前两种方法不必多说,着重演示下第3种,如下:

class MyThread3{
    public static ThreadLocal<Integer> m_intHolder = new ThreadLocal<Integer>(){
        public Integer initialValue(){
            Random r = new Random();
            return new Integer(r.nextInt(100));
        }
    };

    public void Test(){
        Thread[] t = new Thread[2];
        for (int i = 0; i < t.length; i++) {
            t[i]= new Thread(){
                @Override
                public void run() {
                    System.out.println("Thread " + Thread.currentThread().getName() + ", Value="+MyThread3.m_intHolder.get());
                }
            };
        }

        for (int i = 0; i < 2; i++) {
            t[i].start();
        }

        for (int i = 0; i < 2; i++) {
            try {
                t[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

这里每个线程单独输出自己维护的线程名称Thread.currentThread().getName()和线程局部变量MyThread3.m_intHolder,互不影响。

 

使用同步策略

常用的同步策略包括有锁和无锁(原子化操作)两种方式。

有锁同步

针对上述演示的安全性问题,有锁可改成如下

class MyObject4 {
    private int count = 0;

    public int getCount() {
        return this.count;
    }

    public synchronized  void Increment() {
        this.count++;
    }

    public void Decrease() {
        synchronized(this){
            this.count--;
        }
    }
}

这里使用synchronized关键字同步,Increment在整个方法上同步,Decrease在指定语句上同步,实际编程中应该尽量减少同步范围,这样可以较少同步等待范围,提高程序执行效率,也可以避免一些死锁。

上述这个类,两个函数都是同步的,那么针对这个类的操作是不是一定是线程安全的呢?看如下线程中调用:

    @Override
    public void run() {
        if (o != null) {
            o.Increment();
            o.Decrease();

            System.out.println("Thread " + this.s + " run! Value is " + this.o.getCount());
        }
    }

显然,这里多个线程运行时,Increment和Decrease之间还是存在竞态条件,导致getCount输出是不确定的。正确的做法是Increment和Decrease作为一个整体来做加锁,这样才能保证他们是一体完成的,类似数据库的事务操作或者说这两个操作合二为一,是原子的。

无锁同步

针对上述演示的安全性问题,无锁编程,即通常说的原子操作,可改成如下:

class MyObject5 {
    private AtomicInteger count = new AtomicInteger();
    private AtomicReference<BigInteger> count2 = new AtomicReference<>();

    public Integer getCount() {
        return count.get();
    }
    public BigInteger getCount2() { return count2.get(); }

    public synchronized  void Increment() {
        count.getAndIncrement();
    }

    public synchronized  void Increment2() {
        count2.set(new BigInteger("10"));
    }
}

通常我们说的原子操作包括如下两种:

1.一个操作译成字节码后,CPU在执行过程中不会被打断,如上述采用的原子约束操作

2.还有一种原子操作,比如int a=10,Java内存模型要求必须是原子操作(long和double除外),但是此时会有上述演示的内存可见性问题,此时将bStop设置为volatile(对long和double也有效),否则bStop改变后,其他线程观察到的变量值是失效的。这种同步时一种轻量级同步方案,但是这种方法比较脆弱,有时也难理解,一般情况不要采用。

 

设置变量只可读

可以看到,凡是有同步问题的地方,都是有读有写,多个线程冲突或观察失效。如果我们保证多个线程共享的变量,只是可读,谁都不改变它,那么就不存在同步问题了,这就是不可变对象。不可变对象需要满足如下条件:

1.对象创建后状态不可变,不可变条件可在构造函数中初始化

2.对象所有域都是Final类型

3.对象是正确创建的

为了保证变量只读,java提供了Final关键字,指定变量初始化后不可变

常使用的不可变对象同步方法是,保存不可变对象的程序状态可由新的不可变对象替换。如下,每个线程产生一个随机数,计算它的5次方,为了加快计算,保存上一次计算的结果,这里构造一个值a和其5次方b的不可变对象,在没有命中上次值时用新的不可变对象替换上次的。

class MyObject6{
    public final int a;
    public final int b;

    MyObject6(int a, int b){
        this.a = a;
        this.b = b;
    }

    public int getA() {
        return this.a;
    }

    public int getB() {
        return this.b;
    }
}

class MyThread6 extends Thread {
    private static volatile MyObject6 mo = new MyObject6(0, 0);

    public void run() {
        Random r = new Random();
        int x = r.nextInt();

        MyObject6 o = mo;
        if(x==o.getA()){
            System.out.println(o.getA()+"=>"+o.getB());
        }
        else{
            mo = new MyObject6(x, x*x*x*x*x);//创建新的不可变对象来缓存
        }
    }
}

在这里,除了构造构成,始终没有改变不可变对象,但是不可变对象的引用发生了变化,注意这里必须使用volatile保证多个线程间的内存可见性

 

安全发布

之前我们说的同步都是变量已经存在时的同步策略,现在想想另外一种情况,在变量初始化过程中,多个线程访问变量,比如带构造函数的类对象正在初始化过程中,不同的线程看到的对象状态都是不一样的。变量从无到能被多个线程访问,称为对象的发布,如何保证发布的安全性呢,这其实也是安全的单实例方法研究的内容

Java提供很多机制保证安全发布,如下:

1.静态初始化函数中初始化一个对象的引用,这是由JVM 类加载同步机制决定的,它会在类初始化阶段运行

2.Final域中保存对象引用,Java 内存模型要求Final域保证初始化过程中的安全性,共享这些对象无需同步

3.对象引用保存到原子或锁保护的域中

 

演示代码下载链接

原创,转载请注明来自http://blog.csdn.net/wenzhou1219

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值