多线程核心技术梳理(持续更新)

线程池的销毁

1.interrupt()中断线程,让run()执行结束。
2.stop()抛出异常中断,不推荐使用。
3.run()中的线程安全执行结束。

Synchronized与lock的区别

1、synchronized是java内置关键字在jvm层面,Lock是juc包下面的工具类

2、synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁

3、synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可中断、可公平(两者皆可)

4.synchronized会自动释放锁(a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁),Lock需在finally中手工释放锁(unlock()方法释放锁)否则容易造成线程死锁;

5.用synchronized关键字的两个线程1和线程2,如果当前线程1获得锁,线程2线程等待。如果线程1阻塞,线程2则会一直等待下去,而Lock锁就不一定会等待下去,如果尝试获取不到锁,线程可以不用一直等待就结束了;

线程池并行计算(堵塞)

https://blog.csdn.net/xiaowanzi_zj/article/details/123415201

DelayQueue延迟队列

https://blog.csdn.net/xiaowanzi_zj/article/details/125586112

Fork/Join原理分析

https://blog.csdn.net/xiaowanzi_zj/article/details/125610955

ThreadLocal原理分析

https://blog.csdn.net/xiaowanzi_zj/article/details/125691145

 ThreadLocal 是线程共享变量。ThreadLoacl 有一个静态内部类 ThreadLocalMap,其 Key 是 ThreadLocal 对象,值是 Entry 对象,ThreadLocalMap是每个线程私有的。

  • set 给ThreadLocalMap设置值。
  • get 获取ThreadLocalMap。
  • remove 删除ThreadLocalMap类型的对象。

 存在的问题

  1. 对于线程池,由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用,造成一系列问题。
  2. 内存泄漏。由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放,产生内存泄漏。

线程池源码解析

https://blog.csdn.net/xiaowanzi_zj/article/details/122676593

CyclicBarrier源码分析

https://blog.csdn.net/xiaowanzi_zj/article/details/119921796

Semaphore源码分析

https://blog.csdn.net/xiaowanzi_zj/article/details/119880923

ArrayBlockingQueue源码解析

https://blog.csdn.net/xiaowanzi_zj/article/details/118919148

CountDownLatch源码分析

https://blog.csdn.net/xiaowanzi_zj/article/details/118685954

Condition源码分析

https://blog.csdn.net/xiaowanzi_zj/article/details/118655011

ReentrantLock源码分析

https://blog.csdn.net/xiaowanzi_zj/article/details/118652633

手写简单的阻塞队列

https://blog.csdn.net/xiaowanzi_zj/article/details/118884469

简述阻塞队列

阻塞队列是生产者消费者的实现具体组件之一。当阻塞队列为空时,从队列中获取元素的操作将会被阻塞,当阻塞队列满了,往队列添加元素的操作将会被阻塞。具体实现有:

  • ArrayBlockingQueue:底层是由数组组成的有界阻塞队列。
  • LinkedBlockingQueue:底层是由链表组成的有界阻塞队列。
  • PriorityBlockingQueue:阻塞优先队列。
  • DelayQueue:创建元素时可以指定多久才能从队列中获取当前元素
  • SynchronousQueue:不存储元素的阻塞队列,每一个存储必须等待一个取出操作
  • LinkedTransferQueue:与LinkedBlockingQueue相比多一个transfer方法,即如果当前有消费者正等待接收元素,可以把生产者传入的元素立刻传输给消费者。
  • LinkedBlockingDeque:双向阻塞队列。

线程池100并发各数量怎么分配

聊聊你对java并发包下unsafe类的理解

对于 Java 语言,没有直接的指针组件,一般也不能使用偏移量对某块内存进行操作。这些操作相对来讲是安全(safe)的。

Java 有个类叫 Unsafe 类,这个类类使 Java 拥有了像 C 语言的指针一样操作内存空间的能力,同时也带来了指针的问题。这个类可以说是 Java 并发开发的基础。

CAS原理

CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。CAS的原理是拿预期的值和原本的一个值作比较,如果相同更新成新的值。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。
AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作。UnSafe 类的objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址。另外value是一个volatile变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

创建线程池的方式

-newCachedThreadPool->可缓存的线程池
-newFixedThreadPool->固定大小的线程池
-newScheduledThreadPool->可做任务调度的线程池
-newSingleThreadExecutor->单个线程的线程池
-newWorkStealingPool->足够大小的线程池,JDK8新增

线程池7个核心参数

public ThreadPoolExecutor(int corePoolSize,
               int maximumPoolSize,
               long keepAliveTime,
               TimeUnit unit,
               BlockingQueue workQueue,
               ThreadFactory threadFactory,
               RejectedExecutionHandler handler)

-corePoolSize:核心线程的数量,默认不会被回收掉,但是如果设置了allowCoreTimeOut为true,那么当核心线程闲置
时,也会被回收。
-maximumPoolSize:最大线程数量,线程池能容纳的最大容量,上限被CAPACITY限制(2^29-1)后续代码会看到)
-keepAliveTime:闲置线程被回收的时间限制,也就是闲置线程的存活时间
-unit:keepAliveTime的单位
-workQueue:用于存放任务的队列,线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等。
-threadFactory:创建线程的工厂类
-handler:当任务执行失败时,使用handler通知调用者,代表拒绝的策略
1、AbortPolicy:直接抛出异常,默认策略;
2、CallerRunsPolicy:用调用者所在的线程来执行任务;
3、DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务
4、DiscardPolicy:直接丢弃任务;

有的朋友可能还不是很清晰,举个例子,一个公司,核心线程就是代表公司的内部核心员工,最大线程数量就是员工的最大数量,可能包含非内部员工,因为有一些试点或者简单的项目,需要一些外协人员来做,也就是非核心线程,那么当这些项目做完了或者失败了,公司为了节约用人成本,就遣散非核心员工,也就是闲置线程的存活时间。假如核心员工每个人都很忙,但是需求又一波接一波,那就任务排期,也就是任务队列,当任务队列都满了时候,还要来需求?对不起,不接受,直接拒绝,这也就是handler对应的拒绝策略了,可以例子不是很合适,但是主要帮助大家理解下大概的意思。

为什么不建议使用suspend、resume、stop

stop()确实是不安全的。它的不安全主要是针对于第二点:释放该线程所持有的所有的锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放,那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因为suspend方法并不会释放锁,如果使用suspend的目标线程对一个重要的系统资源持有锁,那么没任何线程可以使用这个资源直到要suspend的目标线程被resumed,如果一个线程在resume目标线程之前尝试持有这个重要的系统资源锁再去resume目标线程,这两条线程就相互死锁了,也就冻结线程。

execute和submit的区别

execute只能提交Runnable类型的任务,无返回值。submit既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null。execute在执行任务时,如果遇到异常会直接抛出,而submit不会直接抛出,只有在使用Future的get方法获取返回值时,才会抛出异常。

i++有没有线程安全问题

1.从内存中把i的值取出来放到CPU的寄存器中
2.CPU寄存器的值+1
3.把CPU寄存器的值写回内存

线程池的关闭

ThreadPoolExecutor提供了两个方法,用于线程池的关闭,分别是shutdown()和shutdownNow()
1.shutdown():不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务
2.shutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务

线程池大小的设计

-CPU密集型->保持和cpu核心数量一致,8核8个线程,线程池的最大线程数可以配置为cpu核心数+1
-IO密集型->多设置一些,设置core*2
(一个公式:线程池设定最佳线程数目=((线程池设定的线程等待时间+线程CPU时间)/线程CPU时间)*CPU数目

Java中能够创建volatile数组

可以创建,Volatile对于引用可见,对于数组中的元素不具备可见性

为什么wait/sleep/join都会抛出InterruptedException

他们都是属于阻塞的方法而阻塞方法的释放会取决于一些外部的事件,但是阻塞方法可能因为等不到外部的触发事件而导致无法终止,所以它允许一个线程请求自己(t.interrupt())来停止它正在做的事情。

Java中Runnable和Callable有什么区别

1.Runnable需要实现run()方法,Callable需要实现call()方法
2.Runnable是没有返回值的,而Callable有返回值
3.Runnable的run()方法定义没有抛出任何异常,所以任何的CheckedException都需要在run()实现方法中自行处理,Callable的Call()方法抛出了throwsException,所以可以在call()方法的外部,捕捉到CheckedException。我们看下Callable中异常的处理

有T1/T2/T3三个线程,如何确保他们的执行顺序

join

ThreadLocal如何解决Hash冲突

与HashMap不同,ThreadLocalMap结构非常简单,没有next引用,也就是说ThreadLocalMap中解决Hash冲突的方式并非链表的方式,而是采用线性探测的方式。所谓线性探测,就是根据初始key的hashcode值确定元素在table数组中的位置,如果发现这个位置上已经被其他的key值占用,则利用固定的算法寻找一定步长的下个位置,依次判断,直至找到能够存放的位置。

影响服务器吞吐量的因素

硬件

CPU、内存、磁盘、网络IO

软件

线程数量、JVM内存分配大小、网络通信机制(BIO、NIO、AIO)、磁盘IO

并行和并发(时间片切换)

并行:多个处理器或多核处理器同时处理多个任务。
并发:多个任务在同一个CPU核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
并发=两个队列和一台咖啡机。
并行=两个队列和两台咖啡机。

进程和线程的区别

进程是资源分配的最小单位,线程是CPU调度的最小单位

守护线程是什么

守护线程是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。在 Java 中垃圾回收线程就是特殊的守护线程。

多线程的特点

异步、并行

java中使用多线程

Thread类、Runnable接口、Callable/Future带返回值的、使用线程池例如Executor框架

线程的生命周期

 java

NEW、RUNNABLE、BLOCKED、WAITING、TIME_WAITING、TERMINATED

操作系统

在这里插入图片描述

NEW、READY、RUNNING、WAITING、TERMINATED

线程池的状态

Running、ShutDown、Stop、Tidying、Terminated

线程的主要状态

-初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
-运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的成为“运行”。
 线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池
 中,等待被线程调度选中,获取cpu的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得cpu 时间片
 后变为运行中状态(running)。
-阻塞(BLOCKED):表线程阻塞于锁。
-等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
-超时等待(TIME_WAITING):该状态不同于WAITING,它可以在指定的时间内自行返回。
终止(TERMINATED):表示该线程已经执行完毕。

堵塞的情况

-等待阻塞:运行(running)的线程执行o.wait()方法,JVM会把该线程放入等待队列(waitting queue)中。
-同步阻塞:运行(running)的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池      (lock pool)中。
-其他阻塞:运行(running)的线程执行Thread.sleep(long ms)或t.join()方法,或者发出了I/O请求时,JVM会把  该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入可运行(runnable)状态。

wait:会释放锁,添加到等待队列
notify:不会释放锁,会唤醒等待队列中一个线程移动到同步队列

线程的核心方法

 sleep

sleep方法是属于Thread类中的,sleep过程中线程不会释放锁,只会阻塞线程(TIME_WAITING),让出cpu给其他线程,但是他的监控状态依然保持着,当指定的时间到了又会自动恢复运行状态,可中断,sleep给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会

 wait

​​​​​​wait方法是属于Object类中的,wait过程中线程会释放对象锁,进入等待队列,只有当其他线程调用notify才能唤醒此线程。wait使用时必须先获取对象锁,即必须在synchronized修饰的代码块中使用,那么相应的notify方法同样必须在 synchronized修饰的代码块中使用,如果没有在synchronized修饰的代码块中使用时运行时会抛出IllegalMonitorStateException的异常

yield

sleep是Thread类的方法,都是暂停当前正在执行的线程对象,不会释放资源锁,yield不会导致阻塞,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间,所以执行yield()的线程有可能在进入到可执行状态后马上又被执行。还有一点是yield方法只能使同优先级或更高优先级的线程有执行的机会

join

t.join(long millis):当前线程里调用其它线程t的join方法,当前线程进入WAITING/TIME_WAITING状态, 当前线程不释放已经持有的对象锁。线程t执行完毕或者millis时间到,当前线程进入就绪状态。
join的底层调用的也是wait方法,在synchronized修饰的方法内,notify是在jvm层面实现的。

notify

唤醒在此对象监视器上等待的单个线程,选择是任意性的。notifyAll()唤醒在此对象监视器上等待 的所有线程。wait()和notify()一系列的方法,是属于对象的,不是属于线程的。它们用在线程同步时synchronized语句块中。 

线程的启动和停止和复位

线程的启动

new Thread().start();//启动一个线程,通过调用start0方法在JVM层面通过操作系统创建一个线程
Thread t1= new Thread(),t1.run();//调用实例方法

 线程的终止

thread.interrupt()和volatile变量

interrupt():
1.设置一个共享变量的值true
2.唤醒处于堵塞状态下的线程

package thread;
/**
* 1.thread.interrupt()
* 2.volatile变量
  原理:jvm通过os设置isInterrupted的中断标记为true,通过unpark唤醒堵塞中的线程并抛出异常
*/
public class InterruptDemo extends Thread {
 @Override
 public void run() {
   //isInterrupted默认值是false
   while (!Thread.currentThread().isInterrupted()){
     System.out.println("isInterrupted的值:"+Thread.currentThread().isInterrupted());
     try {
       Thread.sleep(1000);
       //InterruptedException 复位表示false
     } catch (InterruptedException e) {
       System.out.println("isInterrupted的
值:"+Thread.currentThread().isInterrupted());
       //TODO 其它操作
       //中断标记修改为True
       Thread.currentThread().interrupt();
     }
   }
 }
 public static void main(String[] args) throws Exception{
   InterruptDemo interruptDemo = new InterruptDemo();
   interruptDemo.start();
   //设置interrupted为true
   interruptDemo.interrupt();
 }
}

 线程的复位

InterruptedException(被动)和Thread.interrupted()(主动)

package thread;
import java.util.concurrent.TimeUnit;
public class InterruptDemo02 implements Runnable {
 @Override
 public void run() {
   while (!Thread.currentThread().isInterrupted()){
     try {
       TimeUnit.SECONDS.sleep(2000);
     } catch (InterruptedException e) {
       //第一种复位 InterruptedException触发线程的复位,把isInterrupted变量修改为false
       e.printStackTrace();
     }
   }
   System.out.println("processor end");
 }
 public static void main(String[] args) throws InterruptedException {
   Thread thread = new Thread(new InterruptDemo02());
   thread.start();
   Thread.sleep(1000);
   thread.interrupt(); //向线程发送信号,是否中断由线程决定,把isInterrupted变量修改为true
   Thread.interrupted(); //第二种复位方法
 }
}

线程之间的通信

共享内存和消息传递

出现线程安全的的原因

1.存在共享数据(也称临界资源)
2.存在多条线程共同操作共享数据

synchronized三种应用方式

锁的作用范围本质就是对象的声明周期。括号中可以存储任何的对象。抢占锁的本质是互斥。

如何实现互斥?(1、共享资源 2、可以是个标记 0-无锁/1-有锁)

修饰实例方法

 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

修饰静态方法

修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁 

修饰代码块

修饰代码块,给指定加锁对象,进入同步代码库前要获得指定对象的锁 

synchronized方法底层原理

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛出异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现: 

public class SyncMethod {
 public int i;
 public synchronized void syncTask(){
     i++;
 }

使用javap反编译后的字节码如下:

Classfile
/Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
 Last modified 2017-6-2; size 308 bytes
 MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94

 Compiled from "SyncMethod.java"
public class com.zejian.concurrencys.SyncMethod
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool;
 //省略没必要的字节码
 //==================syncTask方法======================
 public synchronized void syncTask();
  descriptor: ()V
  //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
  flags: ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
   stack=3, locals=1, args_size=1
    0: aload_0
    1: dup
    2: getfield    #2          // Field i:I
    5: iconst_1
    6: iadd
    7: putfield    #2          // Field i:I
    10: return
   LineNumberTable:
    line 12: 0
    line 13: 10
}
SourceFile: "SyncMethod.java" 

synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因 。

synchronized代码块底层原理

public class SyncCodeBlock {
 public int i;
 public void syncTask(){
   //同步代码库
   synchronized (this){
     i++;
   }
 }
}

编译上述代码并使用javap反编译后得到字节码如下:

Classfile
/Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.cl
ass
 Last modified 2017-6-2; size 426 bytes
 MD5 checksum c80bc322c87b312de760942820b4fed5
 Compiled from "SyncCodeBlock.java"
public class com.zejian.concurrencys.SyncCodeBlock
 minor version: 0
 major version: 52
 flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
 //........省略常量池中数据
 //构造函数
 public com.zejian.concurrencys.SyncCodeBlock();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
   stack=1, locals=1, args_size=1
    0: aload_0
    1: invokespecial #1          // Method java/lang/Object."":()V
    4: return
   LineNumberTable:
    line 7: 0

 //===========主要看看syncTask方法实现================
 public void syncTask();
  descriptor: ()V
  flags: ACC_PUBLIC
  Code:
   stack=3, locals=3, args_size=1
    0: aload_0
    1: dup
    2: astore_1
    3: monitorenter  //注意此处,进入同步方法
    4: aload_0
    5: dup
    6: getfield    #2       // Field i:I
    9: iconst_1
    10: iadd
    11: putfield    #2       // Field i:I
    14: aload_1
    15: monitorexit  //注意此处,退出同步方法
    16: goto      24
    19: astore_2
    20: aload_1
    21: monitorexit //注意此处,退出同步方法
    22: aload_2
    23: athrow
    24: return
   Exception table:
   //省略其他字节码.......
}
SourceFile: "SyncCodeBlock.java" 

我们主要关注字节码中的如下代码:

3: monitorenter  //进入同步方法
//..........省略其他 
15: monitorexit  //退出同步方法
16: goto      24
//省略其他.......
21: monitorexit //退出同步方法 

从字节码中可知同步语句块的实现使用的是monitorenter和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取objectref(即对象锁) 所对应的monitor的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得monitor,并将计数器值设置为1,取锁成功。如果当前线程已经拥有 objectref 的 monitor的持有权,那它可以重入这
个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor的指令。

对象在内存中的布局

Java对象保存在内存中时,由以下三部分组成:

1. 对象头
2. 实例数据
3. 对齐填充字节

对象头

java的对象头由以下三部分组成:

1. Mark Word 占4个字节
2. Class Metadata Address 类型 Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩( -
XX:-UseCompressedOops )后,长度为8字节。
3. 数组长度(数组有)

synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字节来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度)

Mark Word

存储对象的hashCode、锁信息或分代年龄或GC标志等信息

Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。

JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

JVM一般是这样使用锁和Mark Word的:
1.当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

2.当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

3.当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

4.当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

5.偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

6.轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

7.自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

Class Metadata Address

类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例

数组长度(数组有)

如果对象是一个数组,那在对象头中还必须有一块数据用于记录数组长度

实例数据

对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8比特位、int占4个字节32比特位。

对齐填充

第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpotVM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍)因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全

Synchronized锁升级

减少用户态和内核态的交换,用户态是用户空间(代码等),

 Jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。 

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

默认情况下,偏向锁的开启是有个延迟,默认是4秒。因为JVM虚拟机自己有一些默认启动的线程,这些线程里面有很多的Synchronized代码,这些Synchronized代码启动的时候就会触发竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁的升级和撤销,效率较低。

偏向锁基本原理

       大部分情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁的概念。怎么理解偏向锁呢?
        当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。

偏向锁获取

1.首先获取锁对象的Markword,判断是否处于可偏向状态。(biased_lock=1且ThreadId为空)
2.如果是可偏向状态,则通过CAS操作,把当前线程的ID写入到 MarkWord
a):如果cas成功,那么markword就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块。
b):如果cas失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并 且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行
3.如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的ThreadID
a):如果相等,不需要再次获得锁,可直接执行同步代码块
b):如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁

偏向锁的撤销

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
1.原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程
2.如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
在我们的应用开发中,绝大部分情况下一定会存在2个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过jvm参数UseBiasedLocking来设置开启或关闭偏向锁

流程图分析

轻量级锁基本原理

避免线程堵塞,特点是自旋(CAS),因为抢占锁时间特别短

轻量级锁加锁

1.在线程执行同步代码块之前,JVM会现在当前线程的栈桢中创建用于存储锁记录的空间。
2.然后将锁对象头中的markWord信息复制到锁记录中,这个官方称为 Displaced Mard Word。然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针。如果替换成功,则进入步骤3,失败则进入步骤4。
3.CAS替换成功说明当前线程已获得该锁,此时在栈桢中锁标志位信息也更新为轻量级锁状态:00。此时的栈桢与锁对象头的状态如图二所示。
4.如果CAS替换失败则说明当前时间锁对象已被某个线程占有,那么此时当前线程只有通过自旋的方式去获取锁。如果在自旋
一定次数后仍为获得锁,那么轻量级锁将会升级成重量级锁。
升级成重量级锁带来的变化就是对象头中锁标志位将变为 10(重量级锁),MarkWord中存储的也就是指向互斥量(重量级
锁)的指针。(注意!!!此时,锁对象头的MarkWord已经发生了改变)

自旋锁

轻量级锁在加锁过程中,用到了自旋锁。
所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu的,就相当于在执行一个啥也没有的for循环。
所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。
但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU资源。默认情况下自旋的次数是10次,可以通过preBlockSpin来修改。
在JDK1.6之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。
如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

轻量级锁解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord替换回到锁对象的MarkWord中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。

流程图分析

重量级锁基本原理

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。

重量级锁ObjectMonitor

其中轻量级锁和偏向锁是Java 6对synchronized 锁进行优化后新增加的,这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机
(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

 

ObjectMonitor中有两个队列,WaitSet和EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:

 

多个线程并发访问某个对象监视器(Monitor对象)的时候,即多线程执行Synchonized处的代码时,monitor处理过程包括:

1.thread进入Synchonized代码时,会执行Monitor.Enter命令来获取monitor对象。如果命令执行成功获取。Monitor对象成功,执行失败线程会进入synchronized同步队列中,线程处于BLOCKED,直到monitor对象被释放。
2.thread执行完Synchonized同步代码块后,会执行Monitor.exit命令来释放monitor对象,并通知同步队列会获取 monitor对象。
3.如果线程执行object.wait(),线程会进入synchronized等待队列进行WAITING,直到其他线程线程执行notify() 或notifyAll()方法,将等待队列中的一个或多个等待线程从等待队列中移到同步队列中,被移动的线程状态由 WAITING变为BLOCKED。

volatile关键字(保证可见性) 

通过使用HSDIS工具查看volatile程序的汇编指令,可以看到,使用volatile关键字之后,多了一个Lock指令。

0x00000000037028f3: lock add dword ptr [rsp],0h ;*putstatic stop

 0x0000000002b7ddab: push   0ffffffffc4834800h ;*putstatic stop; -
com.example.threaddemo.VolatileDemo

lock前缀的指令在多核处理器下会引发了两件事情:
-将当前处理器缓存行的数据会写回到系统内存
-这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效
lock汇编指令来保证可见性问题

什么是可见性 

可见性的本质是CPU资源的利用问题。比如(CPU增加高速缓存/操作系统增加进程、线程/编译器JVM的深度优化)

线程A修改了a的值对线程B不可见

硬件层面

  • CPU/内存/IO设备/磁盘/网络
  • CPU层面增加了高速缓存
  • 操作系统,进程、线程、| CPU时间片来切换
  • 编译器的优化,更合理的利用CPU的高速缓存.

CPU层面的高速缓存

因为高速缓存的存在,在多线程情况下会导致一个缓存一致性问题。

CPU高速缓存的出现,虽然提升了CPU的利用率,但是同时也带来了另外一个问题--缓存一致性问题,这个一致性问题体现在。在多线程环境中,当多个线程并行执行加载同一块内存数据时,由于每个CPU都有自己独立的L1、L2缓存,所以每个CPU的这部分缓存空间都会缓存到相同的数据,并且每个CPU执行相关指令时,彼此之间不可见,就会导致缓存的一致性问题。

总线锁&缓存锁

总线锁

在多cpu下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大。

缓存锁

最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个CPU缓存的同一份数据是一致的就行。在P6架构的CPU后,引入了缓存锁,如果当前数据已经被CPU缓存了,并且是要写回到主内存中的,就可以采用缓存锁来解决问题。所谓的缓存锁,就是指内存区域数据如果被缓存在处理器的缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,不再总线上
加锁,而是修改内部的内存地址,基于缓存一致性协议来保证操作的原子性。

缓存一致性协议

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

MESI四种状态:

-M(Modify)表示共享数据只缓存在当前CPU缓存中,并,且是被修改状态也就是缓存的数据和主内存中的数据不一致。
-E(Exclusive)表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改。
-S(Shared)表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致。
-I(Invalid)表示缓存已经失效。

 MESI的一个优化

Store Bufferes

Store Bufferes会导致指令重排序问题

Store Bufferes是一个写的缓冲,对于上述描述的情况,CPU0可以先把写入的操作先存储到Store Bufferes中,Store Bufferes中的指令再按照缓存一致性协议去发起其他CPU缓存行的失效。而同步来说CPU0可以不用等到Acknowledgement,继续往下执行其他指令,直到收到CPU0的Acknowledgement再更新到缓存,再从缓存同步到主内存。 

 

内存屏障禁止指令重排序

CPU层面、jvm层面优化指令的执行顺序。

X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障):

  • Store Memory Barrier(写屏障) ,告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的。
  • Load Memory Barrier(读屏障) ,处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏 障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。
  • Full Memory Barrier(全屏障) ,确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障 后的读写操作。

volatile的作用:通过总线锁和缓存锁来解决缓存一致性问题,从而避免可见性问题、防止指令重排序

软件层面

JMM内存模型

简单来说,JMM定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节。通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

需要注意的是,JMM并没有主动限制执行引擎使用处理器的寄存器和高速缓存来提升指令执行速度,也没主动限制编译器对于指令的重排序,也就是说在JMM这个模型之上,仍然会存在缓存一致性问题和指令重排序问题。JMM是一个抽象模型,它是建立在不同的操作系统和硬件层面之上对问题进行了统一的抽象,然后再Java层面提供了一些高级指令,让用户选择在合适的时候去引入这些高级指令来解决可见性问题。

JMM是如何解决可见性和有序性问题

其实通过前面的内容分析我们发现,导致可见性问题有两个因素,一个是高速缓存导致的可见性问题,另一个是指令重排序。那JMM是如何解决可见性和有序性问题的呢?其实前面在分析硬件层面的内容时,已经提到过了,对于缓存一致性问题,有总线锁和缓存锁,缓存锁是基于MESI协议。而对于指令重排序,硬件层面提供了内存屏障指令。而JMM在这个基础上提供了volatile、final等关键字,使得开发者可以在合适的时候增加相应的关键字来禁止高速缓存和禁止指令重排序来解决可见性和有序性问题。 

Volatile的原理

 通过javap -v VolatileDemo.class

 

 

Happens-Before模型

除了显示引用volatile关键字能够保证可见性以外,在Java中,还有很多的可见性保障的规则。从JDK1.5开始,引入了一个happens-before的概念来阐述多个线程操作共享变量的可见性问题。所以我们可以认为在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程。

程序顺序规则(as-if-serial语义)

一个线程中的每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial。as-if-serial的意思是,不管怎么重排序,单线程的程序的执行结果不能改变。

  • 不能改变程序的执行结果(在单线程环境下,执行的结果不变)。
  • 依赖问题,如果两个指令存在依赖关系,是不允许重排序。 

传递性规则

根据程序顺序规则可以知道,这三者之间存在一个happens-before关系。

int a=2; //A
int b=2; //B
int c=a*b; //C
  • A happens-before B
  • B happens-before C
  • A happens-before C

volatile变量规则

对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作,这个是因为volatile底层通过内存屏障机制防止了指令重排。

监视器锁规则

一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作。

int x=10;
synchronized (this) { // 此处自动加锁
// x 是共享变量, 初始值 =10
if (this.x < 12) {
  this.x = 12;
} 
} // 此处自动解锁

假设x的初始值是10,线程A执行完代码块后,x的值会变成12,执行完成之后会释放锁。 线程B进入代码块时,能够看到线程A对x的写操作,也就是B线程能够看到x=12。

start规则

如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。

public StartDemo{
  int x=0;
  Thread t1 = new Thread(()->{
   // 主线程调用 t1.start() 之前
   // 所有对共享变量的修改,此处皆可见
   // 此例中,x==10
 });
  // 此处对共享变量 x修改
  x = 10;
  // 主线程启动子线程
  t1.start();
}

Join规则

join规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。

Thread t1 = new Thread(()->{
 // 此处对共享变量 x 修改
 x= 100;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 t1 可见
// 主线程启动子线程
t1.start();
t1.join()
// 子线程所有对共享变量的修改
// 在主线程调用 t1.join() 之后皆可见
// 此例中,x==100

final关键字提供了内存屏障的规则.

  • 0
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

泡^泡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值