Java多线程--锁的优化

Java多线程–锁的优化

提高锁的性能

减少锁的持有时间

一个线程如果持有锁太长时间,其他线程就必须等待相应的时间,如果有多个线程都在等待该资源,整体性能必然下降。所有有必要减少单个线程持有锁的时间。比如下面的代码:

public synchronized void someMethods() {
    fun1();
    fun2();
    // other code
    funNeedToSync();
    // other code
    fun3();
    fun4();
}

如果fun1~fun4都是耗时任务的话,对someMethods()进行同步将耗费大量时间,但实际上只有funNeedToSync()需要同步,所以只需要对部分代码进行同步。优化后如下:

public void someMethods() {
    fun1();
    fun2();
    // other code
    synchronized {
        funNeedToSync();
    }
    // other code
    fun3();
    fun4();
}

这样就减少了锁占有的时间。

降低锁粒度

如何要对HashMap的put和get方法进行同步,可以对整个HashMap加锁,这样做锁的粒度就太大了。在JDK1.7中,ConcurrentHashMap的内部进一步细分了若干个小的HashMap,称为段(Segment),默认ConcurrentHashMap被分为16个段。在进行put操作时,根据hashcode得到要存入的值应该被放置到哪个段中,只需对该段加锁即可。如果要存入的值被分配到了不同的段中,则在多线程中可以真正并行进行put操作。注意,ConcurrentHashMap在JDK8中的实现和上述略不同。在这里只是举个锁粒度优化的例子。所谓降低锁粒度,就是指缩小锁定对象的范围,从而减少锁冲突的可能性。

读写分离替代独占锁

因为读取操作并不改变值,所以应该允许多个线程同时读,即读-读不阻塞,比如ReadWriteLock读写锁,在读多写少的情况下能大大提升性能。

锁分离

LinkedBlockingQueue是基于链表实现的,take和put操作分别对队列头和队列尾操作,这两者并不冲突。如果使用独占锁,则需要获得队列的锁,那么在take的时候就不能put,put的时候也不能take;如果锁分离了,如下,正是LinkedBlockingQueue使用的额策略:

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

take操作使用一把锁,put操作也有自己的一把锁,则实现了take和put的操作互相不阻塞。只有在多个take和多个put之间才会有锁竞争的,采用这种策略降低了锁竞争的可能性。

锁粗化

虚拟机在遇到连续对同一个锁不断进行请求和释放的操作时,会把所有的锁操作整合对锁的一次请求,从而减少对锁的请求同步次数。比如

for (int i = 0;i < 100; i++) {
    synchronized (lock) {
        fun();
    }
}

synchronized (lock) {
    for (int i = 0;i < 100; i++) {
        fun();
    }
}

上述的第一段代码对锁lock连续请求、释放了100次…其实只需要在外层申请一次即可。

Java虚拟机对锁的优化

  • 锁偏向:如果一个线程获得了锁,锁就进入了偏向模式。当这个线程再次请求锁时,无需再做任何同步操作。
  • 轻量级锁:如果偏向锁失败,虚拟机不会立即挂起线程,会使用一种称为 轻量级锁 的优化手段,如果线程获得轻量级锁成功,则可以顺利进入临界区。如果轻量级锁夹加锁失败,表示其他线程抢先拿到了锁,当前线程的锁就会膨胀为 重量级锁
  • 自旋锁:锁膨胀后,虚拟机为了避免线程真实地在操作系统层面挂起,虚拟机还会做最后的努力–自旋锁。虚拟机会让当前线程做几个空循环,若干次循环后如果得到了锁,就顺利进入临界区;如果还是没得到,这才真是地将线程在操作系统层面挂起。
  • 锁消除:Java虚拟机在JIT编译时,通过扫描上下文,去除不可能存在共享资源竞争的锁。线程中的局部变量时线程的私有数据,不会跑到其他线程中去,因而不存在“共享”,锁消除的这一项关键技术称为 逃逸分析 ,即观察某一个变量是否会逃出某一个作用域,如果不会逃出,则将线程内的加锁操作去除。

ThreadLocal

使用锁是因为多个线程要访问同一个共享资源。换种思路,如果资源不是共享的,而是每个线程都有一个属于自己的资源呢?ThreadLocal就是这个思路,顾名思义这是一个线程的局部变量。只有当前线程可以访问到,自然是线程安全的。

public static ThreadLocal<SimpleDateFormat> t = new ThreadLocal<>();

// class XXX implements Runnable
@Override
public void run() {
    try {
        if (t.get() == null) {
            t.set(new SimpleDateFormat("yyyy-MM-dd"));
        } else {
            Date d = t.get().parse("2018-05-10");
        }
    } catch (ParseException e) {
        e.printStackTrace();
    }
}

上面举了个ThreadLocal的例子,从代码中可以看到,如果当前线程没有持有一个SimpleDateFormat就为其新建一个,如果有了就直接取出来用,在这里ThreadLocal为没个线程都准备了一个局部变量,这个局部变量在这里就是SimpleDateFormat。注意这里为没个线程设置新的对象t.set(new SimpleDateFormat("yyyy-MM-dd"));保证了线程安全,如果设置的对象是同一个,那不能保证线程安全

set和get是怎么实现设置为每一个线程分配一个局部变量的呢?get和set用到了Map,将当前线程对象和这个局部变量绑定在一起。

get和set的核心实现就是

public void set(T value) {
    // other code
    map.set(this, value); // 以当前线程对象为key,局部对象为value存入map中
    // other code
}

public T get() {
    // other code
    ThreadLocalMap.Entry e = map.getEntry(this); // 以当前线程对象为key,取得当前线程的局部变量
    // other code
}

ThreadLocal的实现用到了ThreadLocalMap,可以理解成一个Map,这个Map中就存放了各个线程的所有“局部变量”。

无锁

无锁使用CAS(Compare And Swap)

CAS包含三个参数(V, E, N)分别是当前内存值V、旧的预期值A、即将更新的值B,当且仅当预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。当多个线程同时使用CAS操作同一个变量时,只有一个线程能成功更新,其余线程均会操作失败。失败的线程不会被挂起,仅被告知失败,还允许再次尝试。CAS操作中这个期望值通俗点说就是当前线程认为这个变量现在的值应该是多少,如果变量的值V并不是期望的那样,说明该变量被其他线程修改过了。当前线程可以再次尝试修改。

锁的使用是悲观的,它总是假设每一次临界区操作都会产生冲突,所以只有一个线程能进入临界区而其他线程只好在临界区外等待;无锁的CAS是乐观的,它假设对资源的访问不存在冲突,那么所有的线程都不用等待,一刻不停地执行,如果真的遇到了冲突,再进行CAS操作,不断重新尝试直到没有冲突。

Java中的无锁类

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

AtomicInteger和Integer都表示整数,但是AtomicInteger是可变且线程安全的,它的内部使用而来CAS操作。类似的还有AtomicLong和AtomicBloolean。

AtomicReference和AtomicInteger类似,前者是对整数的封装,后者是它是对普通对象的封装。之前有说CAS操作会判断当前内存值和期望值是否一致,一致就用新值更新当前值。注意,仅仅是判断了值一致,值变化了多次又变回了原来的样子,CAS操作就无法判断这个对象是否被修改过。也就是说CAS操作只比较最终结果,当前线程无法得知该对象的状态变化过程,如果要获得对象被修改过程的状态变化AtomicReference就不适用了,此时可以使用带时间戳的AtomicStampedReference,不仅维护了对象值,还维护了一个时间戳(就是一个状态值,其实可以不用时间戳),当对象的值被修改时,除了更新数据本身,还要更新时间戳;当设置对象值时,必须满足对象值和时间戳都和期望值一致,写入才会成功。因此即使对象值被反复修改,最后回到了原来的值吗,只要时间戳变了,就能防止不恰当的写入。

Java中也读数组进行了封装,可以使用的原子数组有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,分别表示整数数组、Long型数组和对象数组。

普通变量也可以使用原子操作,有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater,分别对int型ling型和对象的的普通变量进行CAS操作。这几个类可以对对象中的属性字段进行CAS操作而不用担心线程安全的问题,举个例子

public class Demo {
    public static class Student {
        public int id;
        public volatile score;
    }
    public final static AtomicIntegerFielUpdater<Student> scoreUpdater = AtomicIntegerFielUpdater.newUpdater(Sdudent.class, "score");
}

像上面的例子就实现了对Sdudent类的score属性进行CAS操作以保证其线程安全。

AtomicIntegerFieldupdater很好用,但是有几点要注意:

  • Updater使用反射得到这个变量,所以如果变量不可见就会出错。如果上面Student类中score是private的就不可以;
  • 为了保证变量对正确读取,它必须是volatile类型的;
  • CAS操作会通过对象实例中的偏移量直接进行赋值,所以不支持static字段,以为内Unsafe.objectFieldOffset()不支持静态变量。

死锁

死锁就是两个或者多个线程,相互占用着对方需要的资源,都不释放,导致彼此之间相互等待对方释放资源,产生了无限制的等待。举个简单的例子

public class DeadLock implements Runnable {

    public static Object fork1 = new Object();
    public static Object fork2 = new Object();

    private String name;
    private Object tool;

    public DeadLock(Object o) {
        this.tool = o;
        if (tool == fork1) {
            this.name = "哲学家A";
        }
        if (tool == fork2) {
            this.name = "哲学家B";
        }
    }

    @Override
    public void run() {
        if (tool == fork1) {
            synchronized (fork1) {
                try {
                    System.out.println(name+"拿到了一个叉子");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (fork2) {
                    System.out.println(name+"拿到两个叉子了");
                }
            }
        }

        if (tool == fork2) {
            synchronized (fork2) {
                try {
                    System.out.println(name+"拿到了一个叉子");
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (fork1) {
                    System.out.println(name+"拿到两个叉子了");
                }
            }
        }
    }

    public static void main(String[] args) {
        DeadLock a = new DeadLock(fork1);
        DeadLock b = new DeadLock(fork2);

        Thread t1 = new Thread(a);
        Thread t2 = new Thread(b);
        t1.start();
        t2.start();

    }
}

运行上面这段程序,会输出

哲学家B拿到了一个叉子
哲学家A拿到了一个叉子

然后程序就进入了死循环,因为哲学家A在等B手里的叉子,哲学家B也在等A手上的叉子,但是他俩谁都不肯释放。

为了规避死锁,除了使用无锁操作外,还可以使用重入锁。

by @sunhaiyu

2018.5.13

    原文作者:sunhaiyu
    原文地址: https://www.cnblogs.com/sun-haiyu/p/9032377.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞