JVM学习笔记——线程安全与锁优化

线程安全

定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

java语言中的线程安全

讨论线程安全有一个前提,即多个线程之间存在共享数据访问。按照线程安全的“安全程度”由强至弱来排序,可以将Java语言中各种操作共享的数据分为以下5类:不可变、 绝对线程安全、 相对线程安全、 线程兼容和线程对立。

不可变

JDK1.5之后,不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采取任何的线程安全保障措施,Java语言中,如果共享数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。 如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,java.lang.String类的对象就是一个典型的不可变对象,它的substring()、 replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。 保证对象行为不影响其状态的最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的,除了String对象,常用的还有枚举类型,以及java.lang.Number的部分子类,如Long和Double等数值包装类型,BigInteger和BigDecimal等大数据类型。

绝对线程安全

满足绝对要求的东西,通常都要付出很大的代价,这个也不例外,所以java中的绝大多数标注自己是线程安全的类,都不是绝对的线程安全。举个例子,vector类是线程安全的,它的set,get等方法都采用了synchronized同步,但是在某些情况下仍然不安全。

public class Lock {
    private static Vector<Integer> vector = new Vector<Integer>();
    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }
            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++)
                        vector.remove(i);
                }
            });
            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < vector.size(); i++)
                        System.out.println(vector.get(i));
                }
            });
            removeThread.start();
            printThread.start();
            while (Thread.activeCount() > 20) ;
        }
    }

}

运行结果如下:

Exception in thread"Thread-132"java.lang.ArrayIndexOutOfBoundsException:
Array index out of range:17
at java.util.Vector.remove(Vector.java:777)
at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java:21)
at java.lang.Thread.run(Thread.java:662)

尽管这里使用到的Vector的get()、 remove()和size()方法都是同步的,但是在多线程的环境中,如果不在方法调用端做额外的同步措施的话,使用这段代码仍然是不安全的,因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用的话,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException。 解决方案如下:

public class Lock {
    private static Vector<Integer> vector = new Vector<Integer>();

    public static void main(String[] args) {
        while (true) {
            for (int i = 0; i < 10; i++) {
                vector.add(i);
            }
            Thread removeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized (vector) {
                        for (int i = 0; i < vector.size(); i++)
                            vector.remove(i);
                    }
                }
            });
            Thread printThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    synchronized (vector) {
                        for (int i = 0; i < vector.size(); i++)
                            System.out.println(vector.get(i));
                    }
                }
            });
            removeThread.start();
            printThread.start();
            while (Thread.activeCount() > 20) ;
        }
    }

}

相对线程安全

即通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。如上面的代码块,Java语言中,大部分的线程安全类都属于这种类型,例如Vector、 HashTable、Collections的synchronizedCollection()方法包装的集合等。

线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用。

线程对立

线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。 java中线程对立这种排斥多线程的代码是很少出现的,而且通常都是有害的,应当尽量避免。 一个线程对立的例子是Thread类的suspend()和resume()方法,suspend方法并不会释放锁,假设线程A持有某个重要系统资源的锁,然后A被suspend了。这时线程B尝试先持有这个系统资源,然后再resum()A线程。但是很明显无法获取锁,这两个线程就死锁了,也就是冻结线程。也正是由于这个原因,suspend()和resume()方法已经被JDK声明废弃。

线程安全的实现方法

互斥同步

实现互斥同步的方法是一些老生常谈的东西,临界区(Critical Section)、 互斥量(Mutex)和信号量(Semaphore)。这些不做介绍。 在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。 如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。 根据虚拟机规范的要求,在执行monitorenter指令时,首先要尝试获取对象的锁。 如果这个对象没被锁定,或者当前线程已经拥有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。 如果获取对象锁失败,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。 值得注意的两点:

  • synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题
  • 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入

java中阻塞或唤醒一个线程,都需要操作系统从用户态转换到核心态中,状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。 所以synchronized是Java语言中一个重量级(Heavyweight)的操作,除了synchronized之外,我们还可以使用java.util.concurrent(下文称J.U.C)包中的重入锁(ReentrantLock)来实现同步。 关于这两者的区别,可以参考这篇文章。

非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步(Blocking Synchronization)。 无论共享数据是否真的会出现竞争,它都要进行加锁,这样就有了另外一种方法:基于冲突检测的乐观并发策略,即先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施,比如不断重试直到成功。这样就不需要把线程挂起。 非阻塞同步依赖于硬件指令集的发展,因为这样可以保证一些操作的原子性而不需要进行同步操作,比如CAS(compare and swap)。 比如下列的场景:

public class Lock {
    public static AtomicInteger raceSYN = new AtomicInteger(0);
    public static int raceNotSyn = 0;

    public static void increaseSYN() {
        raceSYN.incrementAndGet();
    }
    public static void increase() {
        raceNotSyn++;
    }

    private static final int THREADS_COUNT = 5;

    public static void main(String[] args) throws Exception {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 100; i++) {
                        increaseSYN();
                        increase();
                    }
                }
            });
            threads[i].start();
        }
        while (Thread.activeCount() > 1)
            Thread.yield();
        System.out.println(raceSYN);
        System.out.println(raceNotSyn);
    }
}

结果: raceSYN = 500 raceNotSyn = 499 incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大1的新值赋给自己。 如果失败了,那说明在执行“获取-设置”操作的时候值已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。当时这种方法有局限性,那就是万一操作先加一然后再减一,无法判断值是否被改变过。

无同步方案

如果方法本身就不涉及共享数据,那么明显是线程安全的。以下为典型的两类的介绍: 可重入代码(Reentrant Code):这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。可重入的代码都是线程安全的,但线程安全的代码并不一定是可重入的。 线程本地存储(Thread Local Storage):如果共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

锁优化

自旋锁与自适应自旋

在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。 这时,可以让后面请求锁的那个线程执行一个忙循环(自旋),但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁,这项技术就是所谓的自旋锁。 值得注意的是,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。 因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。 自适应自旋的概念让我想起局部性原理,即自旋锁的事件不固定,而是由前一次的自旋时间以及锁的拥有者的状态决定的,如果上一次成功了,那么这一次会允许较长的自旋时间,如果上一次失败,则会直接跳过自旋状态直接挂起。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。很多的同步措施并不是程序员自己加入的,而是JVM在运行期间转换导致的。

锁粗化

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了。

轻量级锁与偏向锁

量级锁并不是用来代替重量级锁(传统的锁机制)的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。 偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。 如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券