Java虚拟机(十八)--锁与并发、内存模型

计算机想高效的处理计算任务,只靠提高CPU性能是不够的,因为CPU与内存的交互操作占用的时间更长,CPU的运算性能与存储设备的读取速度有几个数量级的差距。所以在CPU与内存之间还有多级缓存。
将数据复制到缓存中,CPU从缓存中获取数据运算,再将结果返回至缓存,缓存再同步到内存。

高速缓存解决了处理器与内存的速度矛盾,但是又衍生了新的问题:缓存一致性。
当多个处理器的运算任务都涉及同一个主内存区域时,可能导致各自缓存数据不一致。

在这里插入图片描述
为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将结果重组,保证结果与顺序执行的结果一致,但不能保证程序中各个语句计算的先后顺序与输入代码的顺序一致。因此如果存在一个计算任务因爱另一个计算任务的中间结果,那么其顺序并不能靠代码的先后顺序来保证。
jvm的即时编译器也有类似的指令重排序优化。

jvm中主内存主要指堆,工作内存主要指栈以及本地线程缓冲区TLAB。


当一个volatile变量被定义之后,它将具备两种特性:

  1. 第一是保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值之后,新值对于其它线程是可以立即得知的,但是各个线程的工作内存中存储的变量值并不一定都相等,只是再每次用之前都会刷新,执行引擎看不到不一致的情况。但是volatile的运算在并发情况下一样是不安全的。
  2. 禁止指令重排序优化

关于volatile的运算在并发情况下一样是不安全的,下面进行了20*10000的运算,理想结果应该是200 000。事实上只有149158左右。所以说明volatile修饰的变量参与运算后,并不安全。

public static volatile int race=0;
    public static void increase(){
        race++;
    }
    public static void main(String[] args) {
        Thread[] threads=new Thread[20];

        for (int i=0;i<threads.length;i++){
            threads[i]=new Thread(()->{
               for (int j=0;j<10000;j++){
                   increase();
               }
            });
            threads[i].start();
        }
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println(race);
    }

查看字节码:
在iconst_1、iadd指令执行时,其它线程可能已经修改了race的值,所以不安全。当然即使是一条字节码也不一定就安全,因为一条字节码也是由多行机器码组成的。

  Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #19                 // Field race:I
         3: iconst_1
         4: iadd
         5: putstatic     #19                 // Field race:I
         8: return

volatile变量只保证可见性,所以在符合以下2个条件时,才可以使用:

  1. 运算结果并不依赖变量的当前值,或者能保证只有单一的线程修改变量的值
  2. 变量不需要与其它状态变量共同参与不变约束

对于禁止指令重排,是因为在给volate修饰的变量赋值后,会有一个lock的机器指令,这个指令相当于一个内存屏障,会把这个CPU缓存中的内容立刻写到主内存中。

在volatile与synchronized之间的选择,主要看volatile能不能满足需求,大多数情况下用volatile的开销要比synchronized小。

jvm允许对long,double这2个64位的数据类型的变量在没有被volatile修饰的情况下,分2次32位的操作来完成读写操作。但是现在的商用虚拟机基本上都把这些操作实现为原子操作。所以一般不需要给long和double声明为volatile。

final修饰的字段具有天然可见性。


Thread类中大部分方法都是native的,所以线程模型是和平台相关的。
实现线程的主要方式有3种:

  1. 使用内核线程实现
  2. 使用用户线程实现
  3. 使用用户线程混合轻量级进程实现

内核线程(KLT)直接由操作系统内核调度,并负责将线程任务映射到各个处理器上。每个内核线程可以视为内核的一个分身。

但是程序一般不直接使用内核线程,而是使用内核线程的一种高级接口–轻量级进程(LWP),也就是通产意义上的线程,每一个线程都有一个内核线程支持。
在这里插入图片描述
但是轻量级线程都是基于内核线程实现,系统调用代价高,需要在用户态与内核态切换,而且还占用内核资源。因此一个系统支持的轻量级进程数量是有限的。


狭义的用户线程(UT)是指完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现,用户线程的建立、同步、销毁和调度完全在用户态完成,不需要内核的帮助。缺点是没有内核的支持,所有线程操作由用户自己的程序处理,线程的创建、切换和调度都是需要考虑的问题,所以现在用的极少。


用户线程与内核线程一起使用,系统提供轻量级进程作为用户线程和内核线程的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,用户需要的系统调用通过轻量级线程来完成,用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模并发。这种模式用户线程与轻量级进程比是不定的,N:M关系。

在这里插入图片描述


Java线程的实现
Java1.2之后,线程模型就是基于操作系统原生的线程模型实现。
线程模型只对并发规模和操作成本产生影响,对Java程序透明。

sun jdk,windows和Linux版都是使用一对一线程模型实现的。


线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有2种,分别是协同式调度和抢占式调度。
Java使用的是抢占式调度。
新建(new),运行(runable),等待(waiting),休眠(sleep),阻塞(blocked)


由于Java线程的一对一模型,Java的线程是映射到操作系统的原声线程之上的,如果阻塞和唤醒一个线程,都需要操作系统帮忙,这就需要用户态与核心态的切换因此需要很多时间。
所以synchronized是一个重量级操作。
而重入锁(ReentrantLock),和synchronized相似,但是前者增加了一些高级功能:等待可中断,可实现公平锁以及锁可以绑定多种条件。
等待可中断:指长时间获取不到锁时,可以放弃然后去做别的事情。
公平锁:指多个线程在等待同一个锁时,按申请先后顺序获取锁。ReentrantLock默认是非公平锁,也就是锁被释放后,这些线程抢占锁。
锁绑定多种条件:指ReentrantLock对想可以同时绑定多种Condition对象。

synchronized和ReentrantLock性能差不多。不是必要的话,使用synchronized就行。


上面是阻塞同步,也就是互斥同步。它最大的问题是线程唤醒和阻塞带来的性能问题,这种同步是悲观的并发策略。
乐观的并发策略是不断的尝试,只要没有其它线程争用数据,就成功了,否则就再试,这种并发策略不需要把线程挂起。也叫做非阻塞同步

乐观并发策略需要硬件支持,硬件保证那些语义上需要多次操作的行为只通过一条处理器指令就能完成。这类指令通常有:

  • 测试并设置 (Test-and-Set)
  • 获取并增加(Fetch-and-Increment)
  • 交换 (Swap)
  • 比较并交换(CAS)
  • 加载链接/条件存储(LL/SC)

CAS指令需要3个操作数,分别是内存位置(V),旧的预期值和新值。仅当V处的值符合旧的预期值时,才执行更新。无论是否更新都返回V的旧值。

每一个Thread对象都有一个ThreadLocalMap对象,对象中存储了一组threadLocalHashCode为键,本地线程变量为值的K-V值对。
ThreadLocal对象包含一个独一无二的hashcode。这就是本地线程变量。
也就是说一个thread对象有一个Map里面有很多个threadLocal。


锁优化
锁优化技术有:适应性自旋,锁粗化,轻量级锁和偏向锁等。

自旋锁是指线程不能获取锁时,空转一会,直到获取锁。JDK1.6后默认开启,自旋次数默认10次,超过自旋次数,则挂起线程,可以使用-XX:PreBlockSpin来更改。1.6还引入了自适应的自旋锁,自旋时间不在固定,而是由前一次在同一个锁上的自旋时间及锁的持有者的状态决定的,如果自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么这次自旋很有可能再次成功,进而允许自旋等待更长的时间。如果对某个锁自旋很少成功过,则可能省略掉自旋过程。

锁消除–依据逃逸分析结果,去除不需要的锁。

锁粗化–扩大锁的作用范围,一般是一块代码不断对一个对象加锁解锁时发生。

轻量级锁
对象在jvm中分为对象头和数据体。
对象头由_mark(存储对象的哈希码,GC标志,GC年龄,同步锁信息)和_metadata组成,在不开启指针压缩的情况下,各占用一个指针宽度。

lock,unlock锁一般是作用于对象的_mark标记的。

由于对象头长度固定,所以_mark被设计为非固定的数据结构以便在极小的空间内存储更多的信息。

在32位的虚拟机中,对象未被锁定时
25位存储对象哈希码,4位存储对象分代年龄,2位存储标志位,1位固定为0.

对象不同的存储标志位,_mark所存储的内容以及对象状态如下:

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 膨胀
空,不需要记录信息 11 GC标记
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

在代码进入同块后,如果对象没有被锁定(标志位01),虚拟机先在线程的栈帧中创建一个锁记录,用于存储锁对象目前的_mark拷贝以及对象引用地址。然后使用CAS操作将_mark更新为指向LockRecord指针,并将锁的标志位置为00。线程就获得了该对象的锁。
在这里插入图片描述

轻量级锁释放时,通过CAS替换,mark word仍然指向线程的锁记录,就替换,如果替换成功,整个过程就完成了,替换失败说明其它线程尝试过获取锁,那么就要在释放锁的同时,唤醒被挂起的线程。
2个线程同时竞争一个锁,没得到的那个线程,会把对象的_mark的标志位置为10,也就是膨胀为重量级锁。
所以没有竞争时,轻量级锁好用。锁竞争剧烈的话,轻量级锁比传统锁更慢。

偏向锁:
偏向锁是在无竞争的情况下,将同步消除掉,CAS都省了。

_mark指向第一个获取它的线程,如果另一个线程尝试获取锁时,偏向模式结束,标志位置为01或者00。

锁竞争激烈的话,偏向锁没用。可以使用-XX:UseBiasedLocking 来禁止偏向锁。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/ljz2016/article/details/83111857
今日推荐