Java并发编程(三)——锁的优化

提高锁的性能

减少锁的持有时间

减少锁的持有时间,有助于降低所冲突的可能性,进而提升系统的并发能力。例如:

public synchronized void syncMethod(){
    othercode1();
    mutexMethod();
    othercode2();
}

改为只在必要的时候进行同步:

public void syncMethod2(){
    othercode1();
    synchronized (this){
        mutexMethod();
    }
    othercode2();
}

再如Pattern类中的matcher() 方法:

public Matcher matcher(CharSequence input) {
    if (!compiled) {
        synchronized(this) {
            if (!compiled)
                compile();
        }
    }
    Matcher m = new Matcher(this, input);
    return m;
}

matcher() 方法有条件地申请行锁,只在表达式未编译时局部加锁。

减小锁的粒度

减小锁的粒度,就是缩小锁定对象的范围,从而降低锁冲突的可能性。
例如ConcurrentHashMap类,内部进一步划分为若干个小的HashMap,称之为段(Segment)。如果在ConcurrentHashMap中添加一个新的表项,并不是对整个HashMap加锁,而是根据hashcode得到的该表项应该存在哪个段中,对该段进行加锁,然后完成put() 操作。

读写锁替换独占锁

对系统功能点进行分割,在读多写少的情况下有效提升系统的并发能力。

锁分离

将读写锁的思想进一步延伸。依据应用程序的功能特点,对独占锁进行分离。
例如在java.util.concurrent.LinkedBlockingQueue的实现中,take() 方法和put() 方法分别是往队列中取数据和增加数据。因此两个操作分别是在队头队尾,理论上来说,两者不会发生冲突。如果使用独占锁,则要求两种操作进行时需要获得当前队列的独占锁,那么take() 方法和put() 方法就不能实现真正的并发。

锁粗化

锁粗化的思想与减少锁的持有时间是相反的,在不同的场合下根据实际情况进行权衡。
例如对同一个锁不停地进行请求、同步和释放,其本身也会消耗系统资源,反而不利于性能的优化。此时,可以将所有对锁的操作整合为一次请求,减少对锁的同步请求次数。

public void demoMethod(){
    synchronized (lock){
        // 同步操作
    }
    // 无需同步的操作,但很快可以执行完毕
    synchronized (lock){
        // 同步操作
    }
}

整合为:

public void demoMethod(){
    synchronized (lock){
        // 同步操作
        // 无需同步的操作,但很快可以执行完毕
        // 同步操作
    }
}

再如:

for (int i = 0; i < circle; i++) {
    synchronized (lock){
        // 同步操作
    }
}

整合为:

synchronized (lock){
    for (int i = 0; i < circle; i++) {
        // 同步操作
    }
}

JVM中对锁的优化策略

锁偏向

针对加锁操作的优化手段。核心思想是:如果有一个线程获得了锁,那么锁进入偏向模式,当这个线程再次请求锁时,无需再做任何同步操作。
对于几乎没有锁竞争的场合,偏向锁有比较好的优化效果。而对于锁竞争比较激烈的场合,最有可能的情况是每次都是不同的线程来请求相同的锁,使偏向模式失效,效果不佳。

轻量级锁

如果偏向锁失败,虚拟机不会立刻挂起线程,而会使用一种称为轻量级锁的优化手段。轻量级锁的操作为,将对象头部作为指针指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。
如果线程获取轻量级锁成功,则进入临界区。若失败,则膨胀为重量级锁。

自旋锁

在锁膨胀后,为了避免线程真实地在操作系统层面挂起,虚拟机会做最后的努力——自旋锁。虚拟机会让当前线程做几个空循环(自旋的含义),经过若干个循环后如果能够得到锁,就能顺利进入临界区。若不能,才会在操作系统层面挂起。

锁消除

锁消除是一种更为彻底的锁优化方法。Java虚拟机在JIT编译时,通过对运行上下文的扫描,出去不可能存在共享资源竞争的锁,。通过锁消除,可以节省毫无意义的请求锁的时间。
例如在使用一些JDK的内置API时,比如StringBuilder、Vector时,很可能会在不存在并发竞争的场合使用。Vector内部使用了synchronized请求锁。在如下代码中:

public String[] createString() {
	Vector<String> v = new  Vector<String>();
	for (int i = 0;i < 100;i++){
		v.add(Integer.toString(i));
	}
	return v.toArray(new String[]{});
}	

由于变量v只在createString() 函数中使用,因此只是一个局部变量。局部变量是在线程栈上分配的,不会有其他线程访问。这种情况下加锁是没有必要的。
锁消除涉及的一项关键技术为逃逸分析,就是观察某一个变量是否会逃出某一个作用域。

ThreadLocal

除了控制资源的访问之外,还可以通过增加资源来保证所有对象的线程安全。如果说锁的使用是第一种思路,那么ThreadLocal的使用就是第二种思路

ThreadLocal的使用

ThreadLocal是一个线程的局部变量,只有当前线程可以访问,因此是线程安全的。

public class DateParseBadDemo {
    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static class ParseDate implements Runnable {
        private int i = 0;

        public ParseDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                Date t =sdf.parse("2015-03-29 19:29:"+i%60);
                System.out.println(i+":"+t);
            }catch (ParseException e){
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(10);
        for (int i = 0;i<1000;i++){
            es.execute(new ParseDate(i));
        }
    }
}

上述代码在运行中会出现大量的java.lang.NumberFormatException,原因在于SimpleDateFormatparse() 方法是线程不安全的,一种可行的办法是在sdf.parse() 方法的前后加锁。但这里可以采取另一种思路:使用ThreadLocal为每一个线程创建一个SimpleDateFormat对象实例:

static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<>();

public static class ParseDate implements Runnable {
    private int i = 0;

    public ParseDate(int i) {
        this.i = i;
    }

    @Override
    public void run() {
        try {
            if (tl.get() == null) {
                tl.set(new SimpleDateFormat("yyyy-MM-dd "));
            }
            Date t = tl.get().parse("2015-03-29 19:29:" + i % 60);
            System.out.println(i + ":" + t);
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }
}

其中,为每一个线程分配一个对象的工作不是由ThreadLocal来完成的,而是需要在应用层面完成,ThreadLocal只起到了容器的作用。

ThreadLocal的实现原理

ThreadLocal的实现原理如下图所示:
在这里插入图片描述
由于ThreadLocalMap是定义在Thread类内部的,因此只要线程不退出,对象的引用将一直存在。例如在使用线程池时,线程在结束任务之后不一定会退出。如果将一些比较大的对象设置在ThreadLocal中,可能会使系统出现内存泄漏的现象。

  1. 最好的方法是使用ThreadLocal.remove() 方法将变量移除。
  2. 此外由于Entry是弱引用,可以特意写出 tl = null ,使tl所指向的对象更容易被垃圾回收器发现,从而加速回收。

性能影响

如果共享对象由于竞争的处理容易引起性能损失,此时可以考虑ThreadLocal为每一个线程分配单独的对象。例如下面这个例子:多线程下陈胜随机数。

public class ThreadLocalRandom {
    public static final int GEN_COUNT = 10000000;
    public static final int THREAD_COUNT = 4;

    static ExecutorService exe = Executors.newFixedThreadPool(THREAD_COUNT);

    public static Random rnd = new Random(123);

    public static ThreadLocal<Random> tRnd = new ThreadLocal<Random>() {
        @Override
        protected Random initialValue() {
            return new Random(123);
        }
    };


    public static class RndTask implements Callable<Long> {
        private int mode = 0;

        public RndTask(int mode) {
            this.mode = mode;
        }

        public Random getRandom() {
            if (mode == 0) {
                return rnd;
            } else if (mode == 1) {
                return tRnd.get();
            } else {
                return null;
            }
        }

        @Override
        public Long call() throws Exception {
            long b = System.currentTimeMillis();
            for (long i = 0; i < GEN_COUNT; i++) {
                getRandom().nextInt();
            }
            long e = System.currentTimeMillis();
            System.out.println(Thread.currentThread().getName() + " spend" + (e - b) + "ms");
            return e - b;
        }
    }


    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Future<Long>[] futs = new Future[THREAD_COUNT];

        for (int i = 0; i < THREAD_COUNT; i++) {
            futs[i] = exe.submit(new RndTask(0));
        }
        long totaltime = 0;
        for (int i = 0; i < THREAD_COUNT; i++) {
            totaltime += futs[i].get();
        }
        System.out.println("多线程访问同一个Random实例:" + totaltime + "ms");

        // ThreadLocal的情况
        for (int  i = 0;i<THREAD_COUNT;i++){
            futs[i] = exe.submit(new RndTask(1));
        }
        totaltime = 0;
        for (int i = 0; i<THREAD_COUNT;i++){
            totaltime += futs[i].get();
        }
        System.out.println("使用ThreadLocal包装Random实例:"+ totaltime+"ms");
        exe.shutdown();
    }
}
OUTPUT
pool-1-thread-4 spend2653ms
pool-1-thread-2 spend2789ms
pool-1-thread-1 spend2812ms
pool-1-thread-3 spend2814ms
多线程访问同一个Random实例:11068ms
pool-1-thread-3 spend92ms
pool-1-thread-1 spend93ms
pool-1-thread-2 spend93ms
pool-1-thread-4 spend93ms
使用ThreadLocal包装Random实例:371ms

无锁

对于并发控制而言,锁是一种悲观的策略,总是假设每一次临界区操作都会产生冲突。而无锁是一种乐观的策略,它假设对资源是没有冲突的,没有冲突自然不需要等待。无锁策略使用一种叫做比较交换(CAS,Compare and Swap)的技术来鉴别线程的冲突,一旦检测到冲突发生,就重试当前操作直到没有冲突为止。

CAS算法

CAS算法包含三个参数CAS(V,E,N),其中

  • V表示内存中的实际值
  • E表示预期值(也就是旧的值)
  • N表示新值(要将V改为N)

当V值等于E值时(说明中间没有发生修改),才会将V值修改为N值。CAS最终返回当前V的真实值。当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新变量值,其他线程均会失败。失败线程会重新尝试或将线程挂起(阻塞)。

在硬件层面,大多数的现代操作系统都已经支持原子化的CAS指令。在JDK1.5之后,虚拟机就可以使用这种指令来实现并发操作并发数据结构

无锁的线程安全整数:AtomicInteger

在JDK并发包中有一个atomic包,其中包含了一些可以直接使用CAS操作的线程安全的类型。其中最常用的是AtomicInteger。与Integer不同的是,AtomicInteger是可变的,并且是线程安全的,对其进行的任何操作都是用CAS指令进行的。
在这里插入图片描述
AtomicInteger中的核心字段value

private volatile int value;

AutomicInteger使用的例子:

public class AtomicIntegerDemo {
    static AtomicInteger i = new AtomicInteger();

    public static class AddThread implements Runnable {

        @Override
        public void run() {
            for (int k = 0; k < 10000; k++) {
                i.incrementAndGet();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] ts = new Thread[10];
        for (int k = 0; k < 10; k++) {
            ts[k] = new Thread(new AddThread());
        }
        for (int k = 0; k < 10; k++) {
            ts[k].start();
        }
        for (int k = 0; k < 10; k++) {
            ts[k].join();
        }
        System.out.println(i);
    }
}
OUTPUT
100000

和AutomicInteger类似的类还有:AutomicLong、AtomicBoolean、AutomicReference等。

Java中的指针:Unsafe类

Java和C++的一个重要区别就是在Java中无法直接操作一块内存区域,如申请内存和释放内存。但Java中的Unsafe类提供了类似C++手动管理内存的能力。从名字可以看出JDK开发人员并不希望大家使用这个类,仅作为JDK内部使用的一个专属类。

Unsafe类是“final”的,不允许继承。构造方法为私有,只能通过工厂方法获得实例,且当且仅当调用getUnsafe()方法的类为引导类加载器所加载时才合法,否则抛出SecurityException异常。
在这里插入图片描述
其他Unsafe类的相关内容可以参考:
Java中的Unsafe:https://www.jianshu.com/p/db8dce09232d

Unsafe实现CAS操作的例子

在使用AtomicInteger进行累加的例子中,i.incrementAndGet()的具体过程如下:
在这里插入图片描述compareAndSwapInt()方法是一个本地方法,var1位给定的对象,var2为对象内的偏移量,通过这两个参数可以快速定位字段。var4为期望值,var5为要设置的新值。当内存中的实际值与var4相同时,才会将内存中的值改为var5。在getAndAddInt方法中不断循环compareAndSwapInt()方法,直到设置成功。

无锁的对象引用:AtomicReference

AtomicReference与AtomicInteger类似,不同在于AtomicInteger是对整数的封装,AtomicReference是对普通对象的引用,可以保证在修改对象引用时的线程安全性。

ABA问题

ABA问题指的是:在CAS操作中,已经获得了对象的值,准备进行修改前,对象的值被多次修改但最终恢复为原值。此时无法判断该对象是否被修改过。

一般来说,发生ABA的情况很小,且在对于状态不敏感的场景中,即使发生了ABA现象也无关紧要。但某些场景,是否能修改对象的值不仅取决于当前值,还和对象的变化过程有关,此时AtomicReference就失效了。

假设这样一个场景:有一家蛋糕店为了挽留客户,决定为贵宾卡里余额小于20元的客户一次性赠送20元,来刺激客户充值和消费,但是条件是每一个顾客只能被赠送一次。

public class AtomicReferenceDemo {
    static AtomicReference<Integer> money = new AtomicReference<>();
    public static void main(String[] args) {
        // 设置账户初始值小于20,显然是一个需要被充值的账户
        money.set(19);

        // 模拟多个线程同时更新后台数据库,为用户充值
        for (int i = 0; i < 3; i++) {
            new Thread() {
                @Override
                public void run() {
                    while (true) {
                        while (true) {
                            Integer m = money.get();
                            if (m<20){
                                if (money.compareAndSet(m,m+20 )){
                                    System.out.println("余额小于20元,充值成功,余额:"+money.get()+"元");
                                    break;
                                }
                            }else {
//                                System.out.println("余额大于20元,无需充值");
                                break;
                            }
                        }
                    }
                }
            }.start();
        }
        // 用户消费线程,模拟消费行为
        new Thread(){
            @Override
            public void run(){
                for (int i = 0 ;i<100;i++){
                  while (true){
                      Integer m = money.get();
                      if (m>10){
                          System.out.println("大于10元");
                          if (money.compareAndSet(m,m-10)){
                              System.out.println("成功消费10元,余额:"+money.get()+"元");
                              break;
                          }
                      }else {
                          System.out.println("没有足够的资金");
                          break;
                      }
                  }
                }
            }
        }.start();
    }
}

上述代码会进行多次充值,为解决这一问题,JDK提供了AtomicStampedReference。

带有时间戳的对象引用:AtomicStampedReference

AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳(可以用任意整数表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还需更新时间戳。当AtomicStampedReference设置对象的值时,对象的时间戳必须满足期望值,才会写入成功。

AtomicStampedReference的几个API在AtomicRenference的基础上新增了关于时间戳的信息。

// 比较设置,参数依次为:期望值、写入新值、期望时间戳、新时间戳
public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp)
// 获得当前对象引用
public V getReference()
// 获得当前时间戳
public int getStamp()
// 设置当前对象引用和时间戳
public void set(V newReference, int newStamp)

通过AtomicStampedReference修改AtomicReference的例子:

public class AtomicStampedReferenceDemo {
    static AtomicStampedReference<Integer> money = new AtomicStampedReference<>(19, 0);
    public static void main(String[] args) {
        // 模拟多个线程同时更新后台数据库,为用户充值
        for (int i = 0; i < 3; i++) {
            final int timestamp = money.getStamp();
            new Thread() {
                @Override
                public void run() {
                    while (true) {
                        while (true) {
                            Integer m = money.getReference();
                            if (m<20){
                                if (money.compareAndSet(m,m+20,timestamp,timestamp+1 )){
                                    System.out.println("余额小于20元,充值成功,余额:"+money.getReference()+"元");
                                    break;
                                }
                            }else {
//                                System.out.println("余额大于20元,无需充值");
                                break;
                            }
                        }
                    }
                }
            }.start();
        }
        // 用户消费线程,模拟消费行为
        new Thread(){
            @Override
            public void run(){
                for (int i = 0 ;i<100;i++){
                  while (true){
                      int timestamp = money.getStamp();
                      Integer m = money.getReference();
                      if (m>10){
                          System.out.println("大于10元");
                          if (money.compareAndSet(m,m-10,timestamp,timestamp+1)){
                              System.out.println("成功消费10元,余额:"+money.getReference()+"元");
                              break;
                          }
                      }else {
                          System.out.println("没有足够的资金");
                          break;
                      }
                  }
                }
            }
        }.start();
    }
}
output:
余额小于20元,充值成功,余额:39元
大于10元
成功消费10元,余额:29元
大于10元
成功消费10元,余额:19元
大于10元
成功消费10元,余额:9元
没有足够的资金
没有足够的资金
...

无锁数组:AtomicIntegerArray

JDK提供了可用的原子数组有:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray,分别表示整数数组、long型数组和普通对象的数组。

以AtomicIntegerArray为例,本质上是对int[]类型的封装,使用Unsafe类通过CAS的方式控制int[]在多线程下的安全性。

例子:

public class AtomicIntegerArrayDemo {
    static AtomicIntegerArray array = new AtomicIntegerArray(10);

    public static class AddThread implements Runnable{
        @Override
        public void run() {
            for (int k=0;k<10000;k++){
                array.getAndIncrement(k%array.length());
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] ts = new Thread[10];
        for (int k = 0;k<10;k++){
            ts[k]=new Thread(new AddThread());
            ts[k].start();
        }
        for (int k=0;k<10;k++){
            ts[k].join();
        }
        System.out.println(array);
    }
}
output:
[10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000]

让普通变量也使用原子操作:AtomicIntegerFiledUpdater

由于初期考虑不周,或者后期需求发生变化,一些普通变量可能也会有线程安全的需求。如果改动不大,可以简单地修改程序中每一个使用到该变量的地方。但该种方式不符合软件设计中的开闭原则。因此可以通过Updater,以少量的代码修改使普通变量也具有线程安全性。

Updater根据数据类型不同,分为3类:AtomicIntegerFiledUpdater、AtomicLongFiledUpdater和AtomicReferenceFiledUpdater。

举例:假设进行一次选举,选民投一票记为1,否则记为0。最终选票是简单求和。

public class AtomicIntegerFieldUpdaterDemo {
    public static class Candidate {
        int id;
        volatile int score;
    }

    public final static AtomicIntegerFieldUpdater<Candidate> scoreUpdater =
            AtomicIntegerFieldUpdater.newUpdater(Candidate.class, "score");

    // 用于检查Updater是否正确
    public static AtomicInteger allScore = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        final Candidate stu = new Candidate();
        Thread[] t = new Thread[10000];
        for (int i = 0; i < 10000; i++) {
            t[i] = new Thread() {
                @Override
                public void run() {
                    if (Math.random() > 0.4) {
                        scoreUpdater.incrementAndGet(stu);
                        allScore.incrementAndGet();
                    }
                }
            };
            t[i].start();
        }
        for (int i = 0; i < 10000; i++) {
            t[i].join();
        }
        System.out.println("score=" + stu.score);
        System.out.println("allScore=" + allScore);
    }
}
output:
score=5922
allScore=5922

注意事项

  1. Updater只能修改它可见范围内的变量,因为Updater使用反射得到这个变量。比如score声明为private,就是不可行的。
  2. 为了确保变量被正确读取,它必须是volatile的。如果源代码中未声明这个类型,简单添加即可。
  3. 由于CAS操作会通过对象实例中的偏移量直接进行赋值,因此它不支持static字段(Unsafe.objectFieldOffset() 方法不支持静态变量)。

无锁的Vector

SynchronousQueue的实现

死锁

死锁:两个或多个线程相互占用对方需要的资源,而都不进行释放,导致彼此之间相互等待对方释放资源,产生无限制等待的现象。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值