美文网首页
Java虚拟机 -- 高效并发

Java虚拟机 -- 高效并发

作者: TomyZhang | 来源:发表于2019-06-28 10:16 被阅读0次

    一、Java内存模型

    Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。

    1.主内存与工作内存

    Java内存模型规定了所有的变量(包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题)都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成。


    线程、主内存、工作内存三者的交互关系

    Java内存模型中的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

    2.对于volatile型变量的特殊规则

    当一个变量定义为volatile之后,它将具备两种特性:
    第一是保证此变量对所有线程的可见性,这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。

    //VolatileTest
    public class VolatileTest {
        private static final String TAG = "VolatileTest";
        public static volatile int race = 0;
    
        public static void increate() {
            race++;
        }
    
        private static final int THREADS_COUNT = 20;
    
        public void test() {
            Log.d(TAG, "zwm, test");
            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 j=0; j<10000; j++) {
                            increate();
                        }
                    }
                });
                threads[i].start();
            }
            
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    Log.d(TAG, "zwm, race: " + race);
                }
            }, 10000);
        }
    }
    
    //MainActivity
    private void testMethod() {
        VolatileTest volatileTest = new VolatileTest();
        volatileTest.test();
    }
    
    //输出log
    zwm, test
    zwm, race: 189082 //小于200000
    

    由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然需要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。

    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    • 变量不需要与其他的状态变量共同参与不变约束。

    以下场景就很适合使用volatile变量来控制并发,当shoutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来。

    //VolatileTest
    public class VolatileTest {
        private static final String TAG = "VolatileTest";
        private static final int THREADS_COUNT = 10;
        private Thread[] threads;
        private volatile boolean shutdownRequested;
    
        public void shutDown() {
            shutdownRequested = true;
            Log.d(TAG, "zwm, thread: " + Thread.currentThread().getId() + " call shutDown");
        }
    
        public void doWork() {
            while (!shutdownRequested) {
            }
            Log.d(TAG, "zwm, thread: " + Thread.currentThread().getId() + " doWork end");
        }
    
        public void test() {
            Log.d(TAG, "zwm, test");
            threads = new Thread[THREADS_COUNT];
            for(int i=0; i<THREADS_COUNT; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        doWork();
                    }
                });
                threads[i].start();
            }
    
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    shutDown();
                }
            }, 10000);
        }
    }
    
    //MainActivity
    private void testMethod() {
        VolatileTest volatileTest = new VolatileTest();
        volatileTest.test();
    }
    
    //输出log
    zwm, test
    zwm, thread: 3467 doWork end
    zwm, thread: 3463 doWork end
    zwm, thread: 3462 doWork end
    zwm, thread: 2 call shutDown
    zwm, thread: 3465 doWork end
    zwm, thread: 3469 doWork end
    zwm, thread: 3471 doWork end
    zwm, thread: 3464 doWork end
    zwm, thread: 3470 doWork end
    zwm, thread: 3466 doWork end
    zwm, thread: 3468 doWork end
    

    第二是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能够获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

    Map configOptions;
    char[] configText;
    //此变量必须定义为volatile
    volatile boolean initialized = false; 
    
    //假设以下代码在线程A中执行
    //模拟读取配置信息,当读取完成后将initialized设置为true以通知其他线程配置可用
    configOptions = new HashMap();
    configText = readConfigFile(fileName);
    processConfigOptions(configText, configOptions);
    initialized = true;
    
    
    //假设以下代码在线程B中执行
    //等待initialized为true,代表线程A已经把配置信息初始化完成
    while(!initialized) {
        sleep();
    }
    //使用线程A中初始化好的配置信息
    doSomethingWithConfig();
    

    如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A最后一句的代码"initialized = true"被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。

    大多数场景下volatile的总开销仍然要比锁低,我们在volatile与锁之中选择的唯一依据仅仅是volatile的语义能否满足使用场景的需求。

    3.对于long和double型变量的特殊规则

    目前各种平台下的商用虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要把用到的long和double变量专门声明为volatile。

    4.原子性、可见性与有序性

    Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。
    原子性:
    我们大致可以认为基本数据类型的访问读写是具备原子性的。
    另外在synchronized块之间的操作也具备原子性。

    可见性:
    可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存中,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
    另外synchronized和final这两个关键字也能实现可见性。

    有序性:
    如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
    Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

    大部分的并发控制操作都能使用synchronized来完成。

    5.先行发生原则

    先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被B观察到,"影响"包括修改了内存中共享变量的值、发送了消息、调用了方法等。

    下列先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

    • 程序次序规则。
      在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
    • 管程锁定规则。
      一个unlock操作先行发生于后面对同一个锁的lock操作。
    • volatile变量规则。
      对一个volatile变量的写操作先行发生于后面对这个变量的读操作。
    • 线程启动规则。
      Thread对象的start()方法先行发生于此线程的每一个动作。
    • 线程终止规则。
      线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等于手段检测到线程已经终止执行。
    • 线程中断规则。
      对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
    • 对象终结规则。
      一个对象的初始化完成(构造函数执行结束)先行发生于它的finalized()方法的开始。
    • 传递性。
      如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

    Java语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些了。

    例子:

    //以下操作在同一个线程中执行
    int i = 1;
    int j = 2;
    

    根据程序次序规则,"int i = 1"的操作先行发生于"int j = 2",但是"int j = 2"的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性。

    二、Java与线程

    线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程是CPU调度的基本单位)。主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理。

    1.线程的实现

    实现线程主要有3种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。
    使用内核线程实现:


    轻量级进程与内核线程之间1:1的关系

    使用用户线程实现:


    进程与用户线程之间1:N的关系

    使用用户线程加轻量级进程混合实现:


    用户线程与轻量级进程之间N:M的关系

    Java线程的实现:
    在目前的JDK版本中,操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的,这点在不同的平台上没有办法达成一致,虚拟机规范中也并未限定Java线程需要使用哪种线程模型来实现。对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。

    2.Java线程调度

    线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度和抢占式调度。

    Java使用的线程调度方式就是抢占式调度。
    Java语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY),但是线程优先级并不是太靠谱,原因是Java的线程是通过映射到系统的原生线程上来实现的,所以线程调度最终还是取决于操作系统。

    3.状态转换

    线程状态转换关系

    三、线程安全

    1.互斥同步

    在Java中,最基本的互斥同步手段就是synchronized关键字。synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入。由于Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中一个重量级的操作。

    除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,在基本用法上ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性,只是代码写法上有点区别。不过,相比synchronized,ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁,以及可以绑定多个条件。

    • 等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。
    • 公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的。ReentrantLock默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。
    • 锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无需这样做,只需要多次调用newCondition()方法即可。

    在性能方面,synchronized与ReentrantLock基本持平,提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

    2.非阻塞同步

    互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。随着硬件指令集的发展,我们有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

    为什么乐观并发策略需要"硬件指令集的发展"才能进行?因为我们需要操作和冲突检测这两个步骤具备原子性。
    靠什么来保证?如果这里再使用互斥同步来保证就失去意义了,所以我们只能靠硬件来完成这件事情,硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。
    这类指令有:

    • 测试并设置(Test-and-Set)
    • 获取并增加(Fetch-and-Increment)
    • 交换(Swap)
    • 比较并交换(Compare-and-Swap,简称CAS)
    • 加载连接/条件存储(Loaded-Linked/Store-Conditional,简称LL/SC)

    例子:

    //AtomicTest
    public class AtomicTest {
        private static final String TAG = "AtomicTest";
        private static final int THREADS_COUNT = 20;
        private static AtomicInteger race = new AtomicInteger();
    
        public static void increase() {
            race.incrementAndGet(); //归功于incrementAndGet()方法的原子性
        }
    
        public void test() {
            Log.d(TAG, "zwm, test");
            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<10000; i++) {
                            increase();
                        }
                    }
                });
                threads[i].start();
            }
    
            new Handler().postDelayed(new Runnable() {
                @Override
                public void run() {
                    Log.d(TAG, "zwm, race: " + race);
                }
            }, 10000);
        }
    }
    
    //MainActivity
    private void testMethod() {
        AtomicTest atomicTest = new AtomicTest();
        atomicTest.test();
    }
    
    //输出log
    zwm, test
    zwm, race: 200000 //输出结果正确
    
    
    //源码
    public final int incrementAndGet() {
        for(;;) {
            int current = get();
            int next = current + 1;
            if(compareAndSet(current, next)) //使用CAS指令
                return next;
        }
    }
    

    尽管CAS看起来很美,但显然这种操作无法涵盖互斥同步的所有使用场景。

    3.无同步方案

    要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。
    可重入代码:
    这种代码也叫做纯代码,可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。我们可以通过一个简单的原则来判断代码是否具备可重入性:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

    线程本地存储:
    可以通过java.lang.ThreadLocal类来实现线程本地存储的功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

    //ThreadLocalTest
    public class ThreadLocalTest {
        private static final String TAG = "ThreadLocalTest";
        private static final int THREADS_COUNT = 10;
        private ThreadLocal<Integer> race = new ThreadLocal<Integer>() {
            @Override
            protected Integer initialValue() {
                return 100;
            }
        };
    
        public void test() {
            Log.d(TAG, "zwm, test");
            Thread[] threads = new Thread[THREADS_COUNT];
            for(int i=0; i<THREADS_COUNT; i++) {
                threads[i] = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        race.set(race.get() + 20);
                        Log.d(TAG, "zwm, race: " + race.get());
                    }
                });
                threads[i].start();
            }
        }
    }
    
    //MainActivity
    private void testMethod() {
        ThreadLocalTest threadLocalTest = new ThreadLocalTest();
        threadLocalTest.test();
    }
    
    //输出log
    zwm, test
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    zwm, race: 120
    

    四、锁优化

    1.自旋锁与自适应自旋

    互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,由于共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程"稍等一下",但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

    自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。

    在JDK 1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

    2.锁消除

    锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

    3.锁粗化

    原则上,我们在编写代码的时候,总是推荐同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。

    大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样只需要加锁一次就可以了。

    4.轻量级锁

    轻量级锁是JDK 1.6之中加入的新型锁机制,它名字中的"轻量级"是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为"重量级"锁。轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

    HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit,官方称它为"Mark World",它是轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象的话,还会有一个额外的部分用于存储数组长度。对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark World被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

    在代码进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录的空间,用于存储锁对象目前的Mark Word的拷贝。然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark World的锁标志位将转变为轻量级锁定状态。


    轻量级锁CAS操作之前堆栈与对象的状态 轻量级锁CAS操作之后堆栈与对象的状态

    如果这个更新操作失败了,虚拟机首先会检查对象的Mark World是否指向当前线程的栈帧,如果指向说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,Mark World中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

    解锁过程也是通过CAS操作来进行的,如果对象的Mark World仍然指向着线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Mark World替换回来,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

    轻量级锁能提升程序同步性能的依据是"对于绝大部分的锁,在整个同步周期内都是不存在竞争的",这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

    5.偏向锁

    偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。

    当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark World之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态,后续的同步操作将按照轻量级锁那样执行。


    偏向锁、轻量级锁的状态转化及对象MarkWorld的关系

    偏向锁可以提高带有同步但无竞争的程序性能。它同样是一个带有效益权衡性质的优化,也就是说,它并不一定总是对程序运行有利,如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候禁用偏向锁优化反而可以提升性能。

    总结

    偏向锁、轻量级锁都是乐观锁,重量级锁是悲观锁。

    一个对象刚开始实例化的时候,没有任何线程来访问它的时候,它是可偏向的,意味着它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时对象持有偏向锁,偏向第一个线程。这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象是偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果原来的线程挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁(偏向锁就是这个时候升级为轻量级锁的)。如果不需要持有偏向锁了,则可以将对象恢复成无锁状态,然后重新偏向。

    轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个线程在自旋,又有第三个线程来访时,轻量级锁膨胀为重量级锁,重量级锁是除了拥有锁的线程以外的线程都阻塞,防止CPU空转。

    相关文章

      网友评论

          本文标题:Java虚拟机 -- 高效并发

          本文链接:https://www.haomeiwen.com/subject/vktscctx.html