并发-基础

并发:同一CPU执行多个代码,通过时间片进行频繁的线程上下文切换。

并行:多个cpu同时执行多个程序。

上下文切换:通过给每个线程分配cpu时间片来实现这个机制,时间极短,看起来像是多个线程同时进行。通过时间片分配算法来循环执行任务,一个线程的时间片执行完后切换下一个线程执行,切换前会保存上一个任务的状态,下次切换到自己后,加载这个任务状态,继续执行。任务保存到再加载的过程就是一个上下文切换。多线程不一定快,上下文的切换会有开销。

 

如何减少上下文切花:

  • 无锁并发编程,多线程竞争锁时,会引起上下文的切换。避免使用锁----将数据id按照hash取模分段,不同线程处理不同段的数据。
  • 使用CAS算法。
  • 使用最少的线程,避免创建不需要的线程。
  • 协程,单个线程里实现多任务的调度,单线程内维持多个任务的切换。

避免死锁:

  • 避免一个线程占用多个锁资源
  • 使用定时锁

Validate:

轻量级的synchronized,保证共享变量的可见性,一个线程修改,另一个线程能够读取到这个修改的值。不会引起上下文的切换,执行成本低。语义:为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获取这个变量。

  • 内存屏障:通过指令实现对内存操作的顺序限制
  • 缓冲行:cpu高速缓存中可分配的最小存储单位,
  • 原子操作:不可中断地一个或一系列操作
  • 缓存行填充:当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个高速缓存行到适当的缓存。
  • 缓存命中:进行缓存填充时,内存位置仍然是下次处理器访问的地址,则从缓存行中读取---则缓存命中,而不是从内存中读取。
  • 写命中:将操作数写回内存缓存时,会检查这个缓存的内存地址是否在缓存,存在则写回到缓存,而不是写回内存--写命中。
  • 写缺失:有效的缓存行被写入不存在的内存区域。

volatile----引起两个操作:1.将当前处理器缓存行的数据写回到内存系统,2.这个写回内存的操作会使其他cpu里缓存了该内存地址的数据无效。

  ----为了提高速度,处理器不直接与内存通信,而是先将系统内存的数据读到内部缓存(L1,L2等)后再进行操作,但操作是不知道何时会写到内存,如果声明了volatile,则会发送一条lock前缀指令,将这个缓存行的数据写回到系统,为了保证多处理器下缓存的数据保持一致性,就会实现缓存一致性协议,每个处理器会嗅探在总线上传播的数据来检查自己缓存的值是否过期,如果发现自己缓存行中对应的内存地址数据被修改了,把当前缓存行置为无效,当处理器再存读取时,就会重新从系统内存中将最新的数据读取到处理器缓存里。

两个原则:

  1. Lock前缀指令引起处理器缓存写回到内存,lock信号确保处理器能够独占任何共享内存。锁定这块内存区域的缓存行并写回内存,同时使用缓存一致性机制来确保修改的原子性,同时阻止修改由两个以上处理器缓存的内存区域。
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。MESI控制协议,使用嗅探技术保证它的内部缓存、系统内存和其他处理器的缓存的数据在总线上保持一致。

什么时数据总线

CPU总线,是PC系统中最快的总线,也是芯片组与主板的核心。这条总线主要由CPU使用,用来与高速缓存、主存和北桥(或MCH)之间传送信息。

synchronized:

作用:

  1. 确保线程互斥的访问同步代码
  2. 保证共享变量的修改能及时可见
  3. 有效解决重排序问题

synchronized的表现形式

  1. 对于普通方法同步,锁就是当前调用该方法的对象。
  2. 对于静态同步方法,锁就是当前类的class对象,一个类只有一个class对象。
  3. 对于同步方法块,锁是synchronized括号中配置的对象。

jvm中实现的原理:jvm基于进入和退出Monitor对象来实现方法同步和代码块同步,两种实现方式不同。monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit则是插入到方法结束处和异常处,jvm要保证enter和exit命令成对匹配,任何对象都有一个monitor与之关联,当一个monitor被持有,他将处于锁定状态,线程执行到enter时会尝试获取该对象所对应的monitor所有权,即尝试获得该对象的锁。

   字节码  同步代码块是通过 获取对象的monitor,
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

执行monitorexit的线程必须是objectref所对应的monitor的所有者。
指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。 

   字节码
   MONITORENTER
   L0
    LINENUMBER 16 L0
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    LDC "xxxxxxx"
    INVOKEVIRTUAL java/io/PrintStream.println (Ljava/lang/String;)V
   L5
    LINENUMBER 17 L5
    ALOAD 1
    MONITOREXIT


java代码
        synchronized (new First()){
            System.out.println("xxxxxxx");
        }

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

 

java对象头:

synchronized用的锁存在对象头中,对象时数据,则虚拟机用3个字宽存储对象头,非数据则用2个字宽。

     对象头长度

MarkWord(32/64bit)存储对象的hashcode或锁信息等
Class Metadata Adress(32/64)存储的对象类型数据的指针

对象头存储结构

锁状态25bit4bit1bit是否是偏向锁2bits锁标志位
无锁对象的hashcode对象分代年龄001

 

 

 

Mark Word的状态变化 :级别 无锁--》偏向锁--》轻量级锁--》重量级锁。 锁只能升级不能降级

锁状态1bit是否指向偏向锁2bit锁标志位
轻量级锁 00
重量级锁 10
GC标记 11

偏向锁

无锁

1

0

01

01

偏向锁:锁不存在多线程竞争,而且总是由同一线程多次获得,当线程访问同步块并获取锁时,会在对象头里和帧栈中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,每次只需要检查下对象头中的MarkWork里是否存储着指向当前线程的偏向锁。成功则表示线程已获得了锁,失败则在测试MarkWord中偏向锁的标识位是否是为1,没有在使用CAS竞争锁,设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁      ThreadID(54bit)Epoch(2bit)                1                    01

偏向锁的撤销:等到竞争出现才释放锁,当其他线程尝试竞争偏行锁时,持有偏向锁的线程才会释放锁,但是撤销需要等到全局安全点(时间点上没有正在执行的字节码),首先暂停持有偏行锁的线程,然后检查持有偏向锁的线程是否存活,线程不处于活动状态,则对象头设置为无所状态,任然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的MarkWord要么重新偏向于其他锁,要么恢复到无锁或标记对象不适合作为偏向锁,最后唤醒暂停的线程。

关闭偏向锁:jdk6、7、8默认开启,是否延迟开启偏行锁  -XX:BiasedLockingStartupDelay=0(关)  是否开启偏向锁 -XX:-UserBiasedLocking=false(关闭) ,关闭后程序默认进入轻量级锁状态。

轻量级锁:

  • 加锁:执行同步块之前,jvm会先在当前线程的帧栈中创建用于存储锁记录的空间,并将对象头中的MarkWord 复制到锁记录中,然后线程尝试使用CAS将对像头中的MarkWord替换为指向锁记录的指针,如果成功,当前线程获得锁,失败,表示其他线程竞争锁,当前线程尝试通过自旋来获取锁。
  • 解锁:使用原子的CAS操作将Displaed Mark Word替换回对象头,如果成功,则表示没有竞争,失败则当前锁存在竞争,锁就会膨胀形成重量级锁。(为啥锁不能降级?自旋操作会消耗CPU,为了避免无用的自旋,不能从重量级降到轻量级),升级后,其他线程试图获取锁时,都会阻塞住,只有当持有锁线程释放后会唤醒这些正在竞争的锁,再进入下一轮的夺锁。

优点缺点适用场景 
偏向锁加锁解锁不需要额外消耗如果线程间存在锁竞争,会带来额外的锁撤销的损耗适用于只有一个线程访问的同步块场景
轻量级锁竞争的线程不会阻塞,提高程序的响应速度始终得不到锁竞争的线程,会自旋消耗CPU

追求响应时间

同步块执行速度块

重量级锁线程竞争不使用自旋,不会消耗cpu线程阻塞,响应时间慢

追求吞吐量

同步块执行速度较长

锁的一些优化:1.jdk自旋策略---适应性自旋,第一次自旋成功了,第二次自旋次数会更多,失败了,第二次自旋次数会更少。2.锁粗化--将将多次连接在一起的加锁、解锁合并为一次,合并为一个范围更大的锁,如stringBuffer的append操作。3.锁消除:删除不必要的加锁操作,根据代码逃逸技术(jdk8是默认开启代码逃逸技术),判断到一段代码中,堆上数据不会逃逸出当前线程,那么认为这段代码是线程安全的。

原子操作实现原理:

缓存行缓存的最小单位 
比较交换(CAS)CAS操作需要输入两个数值,一个旧值、一个新值,操作前先比较旧值是否发生变化,没有变换交换新值,变化了不交换 
CPU流水线  
内存顺序冲突由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现顺序冲突时,必须清空流水线。 

处理器原子操作的实现:1.使用总线保证原子性。通过总线锁LOCK#信号,一个处理器在总线输出此信号,其他处理器的请求将会被阻塞住。2.使用缓存锁保证原子性,内存区域被缓存在处理器的换存行中,并且在LOCK操作期间被锁定,那么执行锁定回写到内存时,不在时通过LOCK#,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。

java的实现方式:1.使用CAS实现原子操作。循环进行CAS操作直到成功为止。

2.原子操作的三大问题:

  • ABA问题,CAS操作时,会检查值有没有发生变化,如果没有变化则更新,但是如果A变为了B,B又变为了A,那么使用CAS进行检查时会发现它的值没有变化,其实发生变化了。解决办法,增加版本号,每次变量更新把版本号加1,AtomicStampedReference---->当前引用是否等于预期引用,当前标志是否等于预期标志。
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp,
                                 int newStamp) {
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }
  • 循环时间长:自旋CAS如果长时间不成功,会带来非常大的执行开销。
  • 只能保证一个共享变量的原子操作。如果存在多个,可以使用AtomicReference类保证引用对象之间的原子性。
  • 使用锁机制实现原子操作,jvm实现锁的方式都用了循环的CAS,一个线程进入同步块使用循环CAS的方式来获取,退出同步块时使用CAS释放锁。

 

验证锁:

ock:  锁状态标记位,该标记的值不同,整个mark word表示的含义不同。

biased_lock:偏向锁标记,为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。

age:Java GC标记位对象年龄。

identity_hashcode:对象标识Hash码,采用延迟加载技术。当对象使用HashCode()计算后,并会将结果写到该对象头中。当对象被锁定时,该值会移动到线程Monitor中。

thread:持有偏向锁的线程ID和其他信息。这个线程ID并不是JVM分配的线程ID号,和Java Thread中的ID是两个概念。

epoch:偏向时间戳。

ptr_to_lock_record:指向栈中锁记录的指针。

ptr_to_heavyweight_monitor:指向线程Monitor的指针。

借助工具JOL OpenJDK,提供了JOL包,可以帮我们在运行时计算某个对象的大小,是非常好的工具

查看对象内部信息: ClassLayout.parseInstance(obj).toPrintable()

查看对象外部信息:包括引用的对象:GraphLayout.parseInstance(obj).toPrintable()

查看对象占用空间总大小:GraphLayout.parseInstance(obj).totalSize()

<dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.8</version>
        </dependency>

jdk8是默认开启偏向锁的,测试时关闭偏向锁的延迟开启  -XX:BiasedLockingStartupDelay=0

加锁时,当前偏向锁偏向主线程。

主线程和thread存在交替竞争关系,但是thread已经离开了同步块,所以主线程输出为轻量线程。

两个线程同时运行竞争a,当一个线程获取锁后,执行完代码块,释放锁时,发现有另一个线程在竞争该锁,升级为重量锁。

 

java的内存模型

线程间如何通信如何同步

线程间的通信机制:共享内存和消息传递。

同步是指程序用于控制不同线程间操作发生相对顺序的机制。共享模型下同步是显示的,消息传递模式下是隐式的,消息发送必须在消息接收前。

实例域、静态域、数组元素都存储在堆内存中,堆内存在线程之间共享,局部变量、方法参数和异常处理器参数不会在线程间共享,不受内存模型的影响。

JMM

JMM定义了线程和主内存之间的抽线关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程以读/写共享变量的副本。JMM通过控制主内存与每个线程的本地内存之间进行交互。

指令的重排序

执行程序时,为了提高性能,编译器和处理器通常会对指令做重排序。遵循数据依赖行,两个操作同时访问一个变量,一个操作为写,那么存在数据依赖。这样不会对这两个操作进行重排序。

  1. 编译器优化的重排序,不改变单线程语义下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序,采用指令并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行。

源代码到指令序列需要就经过上面3个步骤。1属于编译器,23属于处理器重排序。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(1.LoadLoad。2.StoreStore。3.LoatStore。4.StoreLoad)指令,通过内存屏障指令来禁止特定类型的处理器重排序。

每个处理器都有自己的写缓冲区,仅对它所在的处理器可见,存在处理器对内存的读写操作的执行顺序,不一定于内存实际发生的读写顺序一致的问题。由于处理器都会使用写缓冲区,因此现在的处理器都会允许对 写-读 操作进行重排序。

Happens-before 

如果一个操作执行的结果需要对另一个操作可见,那么两个操作之间必须要存在happens-befores 关系。

  • 程序顺序规则:一个线程中的每个操作,happens-befores于该线程中的任意后续操作。

  • 监视器锁规则:对一个锁的解锁,happens-befores于随后对这个锁的加锁。

  • volatile变量规则:对一个volatile域的写,happens-befores于任意后续对这个volatile域的读。

  • 传递性:如果A happens-befores B,且B happens-befores C ,那么A happens-befores C。

要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。

as-if-serial

不管怎么排序,程序执行结果不能改变。编译器和处理器不会对存在数据依赖关系的操作做重排序。

控制依赖排序在多线程中会影响执行结果。

 

数据竞争:当程序未正确同步,就存在数据竞争。JMM规范对数据竞争的定义

  • 在一个线程中写一个变量。
  • 在另一个线程读同一个变量。
  • 而且写和读没有通过同步来排序。

JMM的顺序一致性-----如程序正确同步,那么程序的执行具有顺序一致性------即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同

顺序一致性内存模型

特性:1.一个线程中的所有操作必须按照程序的顺序来执行。2.所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

任意时间点只能有一个线程可以连接到内存。若线程未将本地内存刷新到主内存,写操作只对自己可见,那么其他线程角度来看认为它没有改变,只有当线程把本地内存刷新到主内存后,写操作才会对其他线程可见。

未同步程序的执行特性

JMM为了保证线程读操作的读取的值不是无中生有,则JVM在堆上分配对象时,首先对内存清零(清零时,默认的初始化已完成),然后才会再上面分配对象。

顺序一致性模型和JMM的差异

  • 顺序一致性模型保证单线程内的操作会按照程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行。
  • 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
  • JMM不保证64位的long和double型变量的写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

总线的工作机制可以把所有处理器对内存的访问以串行化的方式来执行,任意时刻,最多只能有一个处理器可以访问内存。保证单个总线事务之中的内存读写具有原子性(不可中断的一个或一些列操作)

锁和volatile具有相同的内存语义。

 

 

 


版权声明:本文为birdswillbecomdragon原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。