共享可变性设计中存在风险以及解决方法(三)

本篇是《Java虚拟机并发编程》第五章的阅读笔记

本篇解决的是可见性的问题,在(一)(二)的基础上对代码进行重构

package com.periodictask;

import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class EnergySource {
    private final long MAXLEVEL = 100;
    private long level = MAXLEVEL;
    private static final ScheduledThreadPoolExecutor replenishTimer = 
            new ScheduledThreadPoolExecutor(10, 
            new java.util.concurrent.ThreadFactory(){
                public Thread newThread(Runnable runnable){
                    Thread thread = new Thread(runnable);
                    thread.setDaemon(true);
                    return thread;
                }
            });
    private ScheduledFuture<?> replenishTask;

    private EnergySource(){}

    private void init(){
        replenishTask = replenishTimer.scheduleAtFixedRate(new Runnable(){
            public void run(){
                System.out.println(System.nanoTime()/1.0e9);
                replenish();
            }
        }, 0, 1, TimeUnit.SECONDS);
    }

    public static EnergySource create(){
        final EnergySource energySource = new EnergySource();
        energySource.init();
        return energySource;
    }

    public long getUnitsAvailable(){
        return level;
    }

    public boolean useEnergy(final long units){
        if(units>0 && level>=units){
            level -= units;
            return true;
        }
        return false;
    }

    public void stopEnergySource(){
        replenishTask.cancel(false);
    }

    private void replenish(){
        if(level < MAXLEVEL){
            level++;
        }
    }
}

在程序开发中,让线程在合适的时间跨越内存栅栏是很重要的事情

  1. 在(一)中提到过,在构造函数中跨越内存栅栏是不合适的。
  2. 在访问共享变量的时候,必须保证访问共享可变变量level的方法要跨越内存栅栏(即对level的访问进行同步,即读和写是互斥的,否则会读到脏数据)。

如果没能正确的处理好跨越内存栅栏的问题,我们就无法保证所有线程在未来的任意时间段内都能即使看到变量值的变化

有很多方法可以保证让EnergySource的函数都跨越内存栅栏,其中最简单的方法是在所有方法前面加上synchronized关键字。虽然有点简单粗暴,但我们还是先通过这种方法来保证变量的可见性,然后在根据其缺点进行改进(先跑起来,然后再优化)

  1. 所有与读写共享可变变量level有关的方法都需要跨越内存栅栏,所有我们将会把所有的相关方法都用synchronized进行修饰。
  2. 在最初的版本中,由于replenish()里面是一个死循环,所以不能用synchronized进行修饰(原因就是如果同步,就变成该方法就只能由一个线程执行,因为它不会释放锁),而最新的版本则没有这个问题。

//Ensure visibility ... other issues pending
    //...
    public synchronized long getUnitsAvailable(){
        return level;
    }

    public synchronized boolean useEnergy(final long units){
        if(units>0 && level>=units){
            level -= units;
            return true;
        }
        return false;
    }

    public synchronized void stopEnergySource(){
        replenishTask.cancel(false);
    }

    private synchronized void replenish(){
        if(level < MAXLEVEL){
            level++;
        }
    }
}

上面是更该部分的代码

  1. 在上面的代码中,我们在需要访问的level变量函数的上面加上synchronized关键字,这样我们实现了线程安全的变量访问
  2. 访问有final修饰的不可变变量则无需穿越内存栅栏,因为这些值是不会变的,并且(CPU)缓存的值和内存里的值也都是完全相同的

当然,类似于上述做法其实是过于保守的,虽然可以保证线程的安全,但是效率很低。

使用synchronized的问题

  1. synchronized关键字的作用域是整个对象,于是整个程序的并发粒度就被限死在对象级别上,在任意时刻,一个对象最多只能接受一个同步操作。

如果对象上的所有操作(例如在一个集合中添加或删除数据等)都是互斥的,那性能可能还不算太差。

然而如果对象支持多个可以并发执行的操作(如drive()和sing()),并且这些操作需要与其他互斥(drive()和tweet())操作进行同步,那么对象实例级别的同步将会对程序的执行速度产生很大影响。

解决方法

在这种情况下,我们需要在对象的相关方法中创建多个同步点,用细粒度的同步控制来提高并发执行速度。

而在本例中,就没有必要采用对象级别的同步。

因为变量level是EnergySource类里唯一的可变字段,所以我们将同步操作直接作用在它上面。

该方法会产生的问题

当然,这种方法并不能总是奏效,因为如果某个类里面定义了多个字段,那么我们需要对这些字段都进行保护,所以我们可能需要定义多个显示的Lock实例来应对这种情况,在(四)中会说到。

虽然将同步操作直接作用到变量level上是可行的。但是这个方案有个小问题,即Java是不允许对象long这样的基础类型加锁(因为Java在读写long的时候会分两个步骤,这样会导致线程不安全),所以我们需要将level变量的类型从long改为AtomicLong来规避这个限制。如此一来,我们就可以针对改变了的访问实现细粒度的线程安全了。

修改后的代码

package com.enhanceconcurrency;

import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

public class EnergySource {
    private final long MAXLEVEL = 100;
    private final AtomicLong level = new AtomicLong(MAXLEVEL);
    private static final ScheduledThreadPoolExecutor replenishTimer = 
            new ScheduledThreadPoolExecutor(10);
    private ScheduledFuture<?> replenishTask;

    private EnergySource(){}

    private void init(){
        replenishTask = replenishTimer.scheduleAtFixedRate(new Runnable(){
            public void run(){
                System.out.println(System.nanoTime()/1.0e9);
                replenish();
            }
        }, 0, 1, TimeUnit.SECONDS);
    }

    public static EnergySource create(){
        final EnergySource energySource = new EnergySource();
        energySource.init();
        return energySource;
    }

    public long getUnitsAvailable(){
        return level.get();
    }

    public boolean useEnergy(final long units){
        final long currentLevel = level.get();
        if(units>0 && currentLevel>=units){
            return level.compareAndSet(currentLevel, currentLevel - units);
        }
        return false;
    }

    public void stopEnergySource(){
        replenishTask.cancel(false);
    }

    private void replenish(){
        if(level.get() < MAXLEVEL){
            level.incrementAndGet();
        }
    }

    public static void main(String[] args){
        EnergySource.create().useEnergy(10);
    }
}
  1. 由于AtomicLong自身就能保证对其所持有的值在多线程并发环境下的可见性和线程安全性,所以我们去掉了getUnitsAvailable()函数前面点synchronized修饰符。
  2. 基于同样的理由,我们也去掉了useEnergy()函数前面的synchronized修饰符。

这一做法在改进并发的同时还带来了一些语义上的变化。在之前版本中,在检查剩余可用电量时,我们锁住了该函数不然其他任何线程访问。所以只要发现有足够的电量,我们就一定能够获取到。

当然之前也说过,这种做法会严重降低并发度,即当一个线程正在进行操作的时候,所有与该类相关的其他交互操作都会被阻塞。

在改进版本中,多个线程可以在没持有互斥锁的情况下同时竞争电源电量的使用权。如果两个或两个以上的线程同时更改level的值,则只有一个会请求成功,而其他请求则需要重试。显而易见,我们既加快了读操作的速度,同时有增强了写操作的安全性。

3.当然replenish()同样不需要使用互斥锁,因为这里面的操作都是线程安全的。

4.由于程序中极少调用stopEnergySource()方法,因此没必要在这个点上做更细粒度的锁控制,所以最终选择保留synchronized关键字


所以当你在写项目的时候,找到那些需要既不损失线程安全性有需要提高并发度的地方。请检测一些这些地方是否可以用Lock对象类代替针对整个对象实例粒度的synchronized关键字。当然在替换的同时,请保证所有参与读写可变状态的方法都进行恰当的同步

猜你喜欢

转载自blog.csdn.net/qq_24986539/article/details/52434437