《实战Java高并发程序设计》读书笔记(四):锁的优化与注意事项

第四章 锁的优化及注意事项

4.1 有助于提高锁性能的几点建议

1、减少锁持有时间

即只在必要的时候进行同步。

2、减小锁粒度

如ConcurrentHashMap中并不是对整个HashMap进行加锁,而是对其分段,每段分别加锁。

注:JDK1.8以后ConcurrentHashMap取消了Segment分段锁,采用CAS和synchronized来保证并发安全。数据结构跟HashMap1.8的结构类似,数组+链表/红黑二叉树。synchronized只锁定当前链表或红黑二叉树的首节点,这样只要hash不冲突,就不会产生并发,效率又提升N倍。

3、用读写分离锁来替换独占锁

读操作和读操作可以并行,读写操作才需要加锁。

4、锁分离

将读写锁的思想进一步延伸,就是锁分离。例如:LinkedBlockingQueue中,take() 和put() 函数分别实现从队列中取数据和往队列中增加数据的功能。虽然两个函数都对队列做了修改,但LinkedBlockingQueue是基于链表的,两个操作分别位于队列的首端和尾端,两者并不冲突。因此,在JDK的实现中,用两把不同的锁分离了这两个函数,是两者在真正意义上实现了并发。

5、锁粗化

通常情况下,要求每个线程在使用完公共资源后,应该立即释放锁。但是,如果对同一个锁不停地进行请求、同步和释放,也会消耗系统资源。因此,虚拟机在遇到一连串连续地对同一个锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步的次数,这个操作叫做锁的粗化。

4.2 Java虚拟机对锁优化所作出的努力

4.2.1 锁偏向

核心思想是:如果一个线程获得了锁,那么锁就进入偏向模式,当这个线程再次请求锁时,无须再做任何同步操作。对于几乎没有锁竞争的场合,偏向锁的优化效果较好;在竞争激烈的成和,其效果不佳。

4.2.2 轻量级锁

如果偏向锁失败,虚拟机并不会立即挂起线程,而是使用一种称为轻量级锁的优化手段。轻量级锁只是简单地将对象头部作为指针指向持有锁的线程堆栈的内部,来判断一个线程是否持有对象锁。如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁加锁失败,则表示其他线程抢先争夺到了锁,那么当前线程的锁请求就膨胀为重量级锁。

4.2.3 自旋锁

锁膨胀后,为了避免线程在操作系统层面挂起,虚拟机会使用自旋锁。系统假设在不久的将来,线程可以获得这把锁。因此,虚拟机会让当前线程做几个空循环(这也是自旋的含义),在经过若干次循环后,如果可以得到锁,就进入临界区,否则才会挂起。

4.2.4 锁消除

锁消除是一种更彻底的锁优化。Java虚拟机在JIT编译时,通过对运行上下文的扫描去除掉不可能存在共享资源竞争的锁。如:在一个不存在并发竞争的场合使用了Vector。

4.3 人手一支笔:ThreadLocal

除了控制资源的访问外,我们还可以通过增加资源来保证所有对象的线程安全。比如,让100个人填写个人信息表,如果只有一支笔,所有人就得挨个填写。可以准备100支笔,人手一支,那么很快就能完成。

1、ThreadLocal的简单使用

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

public class ThreadLocalDemo {

    static ThreadLocal t1 = new ThreadLocal<>();
//    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-ddHH:mm:ss");

    public static class parseDate implements Runnable {

        int i=0;

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

        @Override
        public void run() {
            try {
                if (t1.get() == null) {
                    t1.set(new SimpleDateFormat("yyyy-MM-ddHH:mm:ss"));
                }
                Date date = t1.get().parse("2019-04-01 19:29:" + i % 60);
                System.out.println(i+":"+date);
            } 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));
        }
    }
}

使用ThreadLocal的前提是在应用层面上为每一个线程分配了不同的对象,如果在应用上为每一个线程分配了相同的对象实例,那么ThreadLocal也不能保证线程安全。

4.4 无锁

对于并发控制而言,锁是一种悲观的策略,它总是假设每一次的临界区操作会产生冲突。而无锁是一种乐观的策略,它会假设对资源的访问是没有冲突的。无锁策略使用一种叫做比较交换(CAS,Compare And Swap)的技术来鉴别线程冲突。

1、与众不同的并发策略:比较交换

与锁相比,比较交换会使程序看起来复杂一些,但是它对死锁问题天然免疫,线程间的相互影响也较小。更重要的是,无锁策略没有锁竞争和线程间调度带来的系统开销,性能更优越。

CAS算法的过程:它包含三个参数CAS(V,E,N),其中,V表示要更新的变量,E表示预期值,N表示新值。仅当要更新的变量V等于预期值E时,才会将V设置为新值N,如果V与E不同,说明已有其他线程做了更新,则当前线程什么也不做。最后,CAS返回当前V的真实值。当多个线程同时使用CAS操作一个变量时,只有一个会胜出并成功更新变量,其余均会失败。失败的进行不会被挂起,仅是被告知失败,并且允许再次尝试。

2、无锁的线程安全整数:AtomicInteger

JDK并发包中有一个atomic包,里面实现了一些直接使用CAS操作的线程安全的类型。

其中,最常用的一个类就是AtomicInteger,可以把它看做一个整数。与Integer不同,它是可变的,并且是线程安全的。对其进行的任何操作都是用CAS指令进行的。

AtomicInteger的使用方法很简单

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

    public static class AddThread implements Runnable {

        @Override
        public void run() {
            for (int j = 0; j < 10000; j++) {
                i.incrementAndGet();//当前值加1,并返回新值
            }
        }
    }

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

和AtomicInteger类似的类还有:AtomicLong用来代表Long型数据;AtomicBoolean表示Boolean型数据;AtomicReference表示对象引用。

3、无锁的对象引用:AtomicReference

它可以保证修改对象引用时的线程安全性。但是有一个问题,如果获取对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过这两次修改后,对象的值又恢复了旧值,那么,当前线程就无法正确判定这个对象是否被修改过。

比如下面这个场景:商家为了挽留客户,决定为贵宾卡里小于20元的客户一次性赠送20元,条件是,每一个客户只能赠送一次。

如果在赠予金额到账的同时,客户进行了一次消费,使得总金额小于20元,并且消费后的金额等于赠予金额之前的金额,那么后台进行就会误以为这个账户还没有进行赠予,出错。下面的程序模拟了这个场景:

public class AtomicReferenceDemo {

    static AtomicReference money = new AtomicReference();

    public static void main(String[] args) {
        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;
                        }
                    }
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}

JDK使用AtomicStampedReference就可以解决这个问题。

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

AtomicReference无法解决上述问题的原因是对象在修改过程中丢失了状态信息,对象值本身与状态画上了等号,因此,只需记录对象在修改过程中的状态值就能解决这个问题。

AtomicStampedReference内部不仅维护了对象值,还维护了一个时间戳(或者叫版本戳,实际上它可以使任何一个整数来表达状态值)。只要时间戳发生变化,就能防止不恰当的写入。主要方法如下:

//构造方法, 传入引用和戳
public AtomicStampedReference(V initialRef, int initialStamp)
//返回引用
public V getReference()
//返回版本戳
public int getStamp()
//如果当前引用 等于 预期值并且 当前版本戳等于预期版本戳, 将更新新的引用和新的版本戳到内存
public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp)
//如果当前引用 等于 预期引用, 将更新新的版本戳到内存
public boolean attemptStamp(V expectedReference, int newStamp)
//设置当前引用的新引用和版本戳
public void set(V newReference, int newStamp) 

解决前面的问题(又称为ABA问题):

public class AtomicStampedReferenceDemo {

    static AtomicStampedReference 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;
                        }
                    }
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}

5、数组也能无锁:AtomicIntegerArray

JDK除了提供基本数据类型以外,还提供AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray等复合结构。

一个简单的例子:

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

    public static class AddThread implements Runnable {

        @Override
        public void run() {
            //对数组内10个元素每个累加1000次
            for (int i = 0; i < 10000; i++) {
                array.getAndIncrement(i % array.length());
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //创建10个线程
        Thread[] ts = new Thread[10];
        for (int i = 0; i < 10; i++) {
            ts[i] = new Thread(new AddThread());
        }
        for (int i = 0; i < 10; i++) {
            ts[i].start();
        }
        for (int i = 0; i < 10; i++) {
            ts[i].join();
        }
        System.out.println(array);
    }
}

 

你可能感兴趣的:(读书笔记)