JVM之线程安全与锁优化(十三)

      在软件业发展初期,程序编写都是以算法为核心的,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维的方式直接站在计算机的角度去抽象问题和结局问题,称为面向过程的编程思想。
      面向对象的编程思想是站在现实世界的角度去抽象和解决问题,它把数据和行为都看做是对象的一部分,这样可以让程序员能以符合现实世界的思维方式来编写和组织程序。
线程安全
定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用其他方法协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。(代码本身封装了所有的必要的正确性保障手段(如互斥同步等),令调用者无需关心多线程问题,更无需自己采取任何措施来。
Java中的线程安全
我们这里讨论的线程安全就限定于多个线程之间存在共享数据访问这个前提。为了更深入的理解线程安全,我们这里不把线程当做非真即假的二元拍它项,按照安全程度强弱排序,java各种操作共享数据分为五类:
不可变
绝对线程安全
相对线程安全
线程兼容
线程独立
》不可变
不可变对象无论是对象的方法实现还是方法的调用者,都不需要采取任何线程安全保障措施,所以它一定是线程安全的。比如final关键字可见性,java中的String对象,它就是一个不可变对象,无论是调用它的substring(),replace(),concat等都不会改变原来的值,只会返回一个新构造的字符号对象。保证对象行为不会影响自己的状态,最简单的方式就是带有状态的变量标记为final,这样构造函数结束之后,它是不可变的。如Integer 中int值定义为:final int value;
》绝对的线程安全
绝对的线程安全就是最上面给出的定义,但是那个太严格,达到绝对的线程安全要花费很大代价。Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。比如下面这个例子:
java.util.Vector是一个线程安全的容器,因为它的add、get、size方法都是被synchronized所修饰,尽管效率低,但是线程安全。(错误的理解:这样只能保证同一个方法,同一个时间只能被同一个线程调用,但是并不能保证其他线程不能调用别的方法,正确的理解: synchronized修饰的方法在vector中是实例方法,因此加锁对象就是代码中创建实例vector,即当一个线程执行remove时,别的线程不能执行get。)

import java.util.Vector;

public class ThreadVector {
    private static Vector<Integer> vector=new Vector<Integer>();
    public static void main(String[] args) {
        while(true){
            for(int i=0;i<4;i++){
                vector.add(i);
            }
            Thread removedThread =new Thread( new Runnable(){

                @Override
                public void run() {
                    // TODO Auto-generated method stub
                    //同一个对象加锁,只有一个线程释放锁后,后一个线程才能执行,相当于串行执行两段代码
                    //synchronized(vector){
                    for(int i=0;i<vector.size();i++){
                        vector.remove(i);
                    }
                //}
                }});
           Thread printThread=new Thread(new Runnable(){

            @Override
            public void run() {
                // TODO Auto-generated method stub
                //加锁同步
                //synchronized(vector){
                for(int i=0;i<vector.size();i++){
                    System.out.println(vector.get(i));
                }
            //}
            }});
           removedThread.start();
           printThread.start();
           while(Thread.activeCount()>20);
        }

    }

}

部分运算结果(隔一段时间就会出现类似如下错误异常):
……..
1Exception in thread “Thread-1128” java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 1

at java.util.Vector.remove(Vector.java:827)
at com.jvm.threadsecurity.ThreadVector$1.run(ThreadVector.java:18)
at java.lang.Thread.run(Thread.java:744)

造成这个结果的原因是当一个线程在执行remove方法后,另一个线程在执行获取get方法,而get方法在

    public synchronized E get(int index) {
        if (index >= elementCount)
            throw new ArrayIndexOutOfBoundsException(index);

        return elementData(index);
    }

从上面可以看出,出错是因为 在获取前先判断elementCount,使得条件成立index >= elementCount,从而抛出异常。
既然对实例对象进行加锁了,同一时刻要么remove方法执行,要么get方法执行,不会同时执行,为什么还会出现在这种情况?
根据代码可以看出其实这和vector无关,主要是因为for循环出的问题:

假如某个时刻,vector中最大下标元素为8,而printThread线程在执行代码for(int i=0;i<vector.size();i++)中i=8,
且小于vector.size9)时,cpu执行时间结束,这个时候还没有获得执行remove方法,也因此没有获得锁;接下来当
removedThread线程在执行remove方法时,比如在执行i=8时,先获得vector锁,假如在刚执行remove(8)后,
cpu时间片结束,,则它会把vector锁释放,这个时候printThread线程就获得锁接着执行get方法,
即get(8),这个时候就会出现这种下标越界情况。

要保证代码安全执行,加锁同步,如上面注释代码加上,这样是对整个for循环进行同步,也就不会出现i越界情况。
注意:sleep时线程不会释放锁资源(如果持有),当cpu时间执行结束,根据情况分析,如果是同步代码块也不会释放锁。
》相对的线程安全
相对的线程安全就是我们通常意义上讲的线程安全,他保证对这个对象单独操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但对于一些特定顺序的连续调用,就可能需要在调用端额外的使用同步手段来保证调用正确性。上面代码就是相对安全的案例。
》线程兼容
线程兼容是指对象本身不是线程安全的,但是调用端正确的使用同步手段来保证对象在并发情况下可以安全的使用。如ArrayList和HashMap等。
》线程独立
线程独立是指调用端无论是否采取同步措施,都无法在多线程下并发使用代码。这种情况很少,也尽量避免。比如Thread类中的suspend和resume方法,如果两个对象同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,并发情况下,是不可能同步的。都会存在死锁风险。
线程安全实现的方法
线程安全是通过代码编写和虚拟机提供同步和锁机制来实现,相对更偏重后者一些 ,因为了解了虚拟机线程安全手段的运作过程,自己编写安全代码就会不那么困难。
1、互斥同步
互斥同步是常见的一种并发正确性保障手段。同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥手段。互斥是因,同步是果;互斥是方法,同步是目的。
Java中最基本的同步互斥手段是synchronized关键字,经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令需要一个reference类型的参数来指明要锁定和解锁的对象。如果java程序中明确指明了这个对象,那就是这个对象的reference,如果没有明确执行,那就根据synchronized修饰的是实例方法还是类方法,去取这个对象实例或Class对象作为所对象。
除了synchronized(支持同一个对象可重入)外,我们还可以使用java.util.concurrent的重入锁(ReentrantLock)来实现同步,基本用法相似,一个表现为API层面的互斥锁(lock()和unlock()方法配合try/finally语句块来执行),另一个表现为原生语法层面上的互斥锁。
不过ReentrantLock增加了一些高级功能,主要三项:
等待可中断:持有锁的线程长期不释放锁,等待的线程可以选择放弃等待。
可实现公平锁:通过(非默认)设置,当多个线程等待同一个锁时,可以按照申请锁的先后时间依次获得锁。而synchronized则不能。
锁可以绑定多个条件:一个ReentrantLock对象可以同时绑定多个condition对象,只需多次调用newCondition()方法,而synchronized中,锁对象的wait和notify或notifyall(这些方法会释放锁)方法可以实现一个隐含条件,如果要多于一个隐含条件关联,则不得不额外加一个锁。
不过,书中作者给出的建议是使用原生的synchronized,因为后面虚拟机改进都是偏向于synchronized。
2、非阻塞同步
互斥同步主要问题是进行线程阻塞和唤醒所带来的性能问题,这种同步也称为阻塞同步。互斥同步属于一种悲观的并发策略,总是认为如果不去做同步措施就会出问题,无论共享数据是否会真的出现竞争。
但是,随着硬件指令集的发展(需要操作和冲突检测这两个步骤具备原子性,不能靠互斥同步,否则没有意义),基于冲突检测的乐观并发策略:先进行操作,如果没有其他线程争用共享数据,那就操作成功,如果出现共享数据争用,产生冲突,那就采取其他措施。这种乐观并发策略许多实现并不需要把线程挂起,因此这种同步操作称为非阻塞同步。
3、无同步方案
保证线程安全并不一定进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性手段,如果一个方法本来不共享数据,因此无需任何同步措施,本身就是线程安全。
主要两类:
可重入代码:也叫纯代码,可以在任何时刻中断它,执行别的代码或调用自身,控制权返回后,原来的程序不会出现任何问题。对于线程安全来说,可重入性是更基本的特性,所有的可重入代码都是线程安全的,但是并非所有的线程安全代码都是可重入的。
可重入的代码有一些特性:不依赖存储在堆上的数据和公共的系统资源,用到的状态量都是参数中传入、不调用非可重入的方法。
判断是否可重入性:如果一个方法返回的结果是可以预测的,只要输入相同的数据,就能返回相同的结果,那她就满足可重入性的要求。
线程本地存储:如果一点代码中所需要的数据必须与其他代码共享,如果能保证这些共享数据的代码都在同一个线程中执行,那么就是可把共享数据限制在线程之内,无需同步。
比如 消费者和生产者模式,消耗过程尽量在一个线程内完成。web交互中的一个请求对应一个服务器线程。
锁优化
1、自旋锁与自适应自旋
互斥同步线程阻塞对性能影响很大,挂起与恢复转入内核态完成,给系统并发带来很大压力。如果物理机有一个以上的处理器,可以使两个或以上的线程同时并行执行,那就可以使请求锁的线程“稍等一会”但不放弃处理器执行时间(空占着处理器),看看持有锁的线程是否很快会释放锁。为了让线程等待,我们只需要线程执行一个忙循环(自旋),这项技术就是所谓的自旋(自旋有一定的限度,否则浪费处理器资源)。因此jdk1.6引入自适应自旋锁,自选时间不固定,而是有前一次在同一个锁上的自旋时间及锁的拥有状态决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么虚拟机就认为这次自旋很有可能再次成功,进而允许自旋等待更长时间。而对于很少成功获得过锁的,则等待获得这个锁时可能省略自旋过程,进行挂起。
2、锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上的要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。锁消除主要判断依据来自逃逸分析。StringBuffer.append();方法就是同步的,但是调用时根据判断来决定是否去除锁。
3、锁粗化
原则上我们在编写代码时,尽量将同步块缩小,但如果对一个对象反复加锁和解锁,甚至出现在循环体中,即时没有线程竞争,频繁互斥操作也会也会带来很大的性能消耗。
4、轻量级锁
本意是在没有多线程竞争的情况下,减少传统重量级锁说用系统互斥量产生的性能消耗,并不是替代重量级锁。用虚拟机中对象头来操作。在代码进入同步块的时候,如果此同步对象没有被锁定(01标志),虚拟机首先将在当前线程的栈帧中建立一个锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。然后虚拟机使用CAS操作尝试将对象的Mark Word更新指向Lock Record。如果操作成功了,那这个线程就拥有了该对象的锁。并且对象的Mark Word标志位设置为“00”,表示轻量级锁。如果更新失败,看是否指向当前线程的栈帧,如果是则说明已经拥有对象锁,直接进入同步块执行,否则说明被其他线程抢占了。
5、偏向锁
jdk1.6中引入,消除数据在无竞争情况下的同步原语,进一步提交程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步去掉,连CAS操作都不做了。
偏向于第一个获得它的线程,在接下来的执行过程中,如果锁没有被其它线程获取,则持有偏向锁的线程永远不需要在进行同步。
当锁对象第一次被线程获取的时候,虚拟机将对象投的标志位设为“01”,偏向模式,同时使用CAS把获取到这个锁的线程ID记录在对象那个mark word中,如果操作成功,那么持有偏向锁的线程以后每次进入同步代码块都不要任何同步操作,如果加锁解锁和更新Mark Word等。但是一旦别的线程尝试获得这个锁,则偏向模式结束。根据锁对象目前是否处于被锁定状态,撤销偏向后恢复到未锁定(01)或者轻量级锁(00)。
偏向锁可以提高带有同步但无竞争的程序性能。

猜你喜欢

转载自blog.csdn.net/qq_26564827/article/details/80723223