多线程、锁与 JVM
  Tgkpp50AtIpa 2023年11月02日 35 0
  1. 什么是双亲委派

这个问题首先要从类的加载机制说起,我们编写的Java文件变成到最终可运行的状态,他必须要经历编译和加载两个过程,而编译的过程,就是把Java文件变成成class文件的过程;而加载的过程,就是把class文件加载到jvm内存里面,加载完成后会得到一个class对象,此时即可使用new关键字来创建对象、实例化等过程。类的加载过程需要使用到类加载器,类的加载过程,主要提供了四种不同的类加载器来完成,他们从下到上是依次是:customeClassLoader-->AppliactionClassLoader-->ExtensionClassLoader-->BootstrapClassLoader,这四种加载器构成了一个完整的类加载模型,英文名 parent delegate model,中文名叫双亲委派,实际上根据他的源码实现方式,理解为父委托模型可能更加合适,他们的关系不是子父类的继承的关系。

  • CustomeClassLoader 加载自定义的class
  • AppliactionClassLoader 加载应用内classpath指定的内容
  • ExtensionClassLoader 加载扩展jar包,jre/lib/ext下的内容或者有参数-Djava.ext.dirs指定的内容
  • BootstrapClassLoader 加载核心类,此部分由C++实现

双亲委派机制,在进行类加载的过程中,他执行的是一个向上委托,向下查找的过程,类在进行加载的时候,首先会一层一层的向上委托进行查找,如果没有找到则从上往下进行执行查找,如果还是没有,则会返回classnotfound的异常。这种机制存在的意义,主要是为了保证核心类的安全,他确定了类加载的优先级,防止核心类被恶意替换和覆盖,同时也为了防止类被重复加载。

  1. 对线程安全的理解

当多线程访问一个对象或某个方法的时候,如果不进行额外的同步控制或其他的协调干预,调用这个对象或者方法,最终都可以获得正确的结果,那么他就是线程安全的。造成线程安全问题的潜在原因是,在每个进程的内存空间,都有一个特殊的公共区域,这个区域通常称为堆,堆内存是进程和线程的共享空间,进程内的所有线程都可以访问到该内存区域,这就导致了线程安全问题的存在,因为在Java中,堆是Java虚拟机所管理的内存中最大的一块,是所有线程的共享区域,他在虚拟机启动的时候创建。堆所在的内存区域的唯一目的就是存放实例对象,几乎所有的实例对象以及数组都在这里分配。线程安全有三个具体的表现:

  • 原子性 表示一个线程在执行一系列操作的时候,他的执行是不可中断的,一旦出现中断,那么就有可能会出现前后执行结果不一致的情况,因此简单的说就是一个程序只能有一个线程完整的执行完成,而不能存在多个线程的干扰。 CPU的上下文切换,是导致原子性问题的核心,在JVM层面提供来一个sychronized关键字来解决原子性问题。
  • 可见性
  • 有序性

解决有序性和可见性,可以通过JVM提供的volatile来解决,导致可见性、有序性的原因是计算机工程师为了最大化提升CPU的利用率导致的,比如为了提升CPU效率,设计了三级缓存、storeBuffer、缓存行这种预读机制,操作系统里面设计了预读模型,编译器的深度优化机制等。

  1. 对volatile的理解

volatile的两个主要作用:

  • 可见性

由于每个工作线程,都有自己的工作缓存,线程会把相关内容从共享堆内存中copy一份到自己的工作线程中去,这就使得当其中某一个属性改变的时候,这个属性在各个线程中是不可见的,导致线程中读取到的属性值还是原来的属性值。使用volatile修饰的属性,在JVM层面会自动增加一个lock汇编指令,而这个指令会根据不同CPU型号添加总线锁或者缓存锁,总线锁是锁住CPU的前端总线,从而确保在同一时刻只有一个线程在和内存通信,这样就避免了多线程并发造成的可见性问题;缓存锁是对CPU总线锁的一个优化,因为总线锁会使CPU的效率大幅下降,因此缓存锁只针对CPU三级缓存中的数据去加锁,CPU层面,使用MESI(CPU缓存一致性协议)。这就使得只要被volatile 修饰的属性一旦改变,就会触发CPU层面的缓存一致性协议,及时的更新该属性值,确保线程中的属性是最新的。

  • 禁止指令重排(有序性)

指令顺序优化的初衷是通过调度CPU的指令执行顺序和异步化的操作来提升CPU的执行效率,指令重排可能发生在编译、CPU指令执行、缓存优化几个阶段,其优化原则是只要能保证最终一致性即可,不影响最终的执行结果,那么就允许指令重排的发生。指令重排的逻辑就是优先执行比较耗时的指令,然后利用指令执行的这段空闲时间来执行其他指令。例如做饭的时候,优先煮米饭,然后利用煮米饭的这段时间进行洗菜、炒菜的动作,不影响最终吃饭的效果。

那么如何禁止重排呢? JVM提供了四种内存屏障:

LoadLoad 读

StoreStore 写

LoadStore 读

StoreLoad 写读

也可以对属性变量使用volatile来修饰,使用volatile修饰的属性会自动带有JVM的这四种内存屏障,来保障指令执行的有序性。

不保证原子性

volatile 虽然保证了线程间的可见性,但是并不能保证原子性,其原因是因为volatile 并没有锁机制,他只是确保了被修饰的属性或方法在线程间的可见性,但是在多线程并发的情况下,并没有对被修饰的属性或方法进行加锁,因此也就无法保证原子性。

从jdk5开始,JMM使用了一种叫happens-before的模型去描述多线程之间的可见性的一个关系,因此只要两个操作之间具备happens-before的关系,就意味着这两个操作具备可见性的关系,也就不需要在额外的增加volatile来保障可见性。

  1. 什么是伪共享

有几个方面的问题,计算机工程师为了提高CPU的利用率,平衡CPU和内存之间存在的一个速度差异,在CPU里面设计了一个三级缓存的东西,CPU在向内存发起IO操作的时候,一次性会读取64个字节的数据作为一个缓存行,在Java里面,一个Long类型是8个字节,意味这一个缓存行可以存储8个Long类型的变量,这样的一个设计是基于空间局部性原理来实现的,也就是说如果一个存储器的位置被引用,那么将来他附近的位置也可能会被引用,所以缓存行的设计,在他和内存进行数据交互的时候,可以有效的减少和内存的交互次数,从而避免CPU和IO的等待,进而提升CPU的利用率。而这种缓存行的设计也会带来其他的问题,如果多个线程同时修改同一个缓存行里面的多个独立变量的时候,基于缓存一致性协议,就会无意中影响了彼此的性能,这就是一个所谓的伪共享的问题。因为伪共享的问题,会导致缓存锁的竞争,所以在并发场景中,程序执行效率会大打折扣,解决问题的办法有两种:

  • 使用对齐填充 增加一些无意义的变量来填充,使其刚好满足64大小,例如有一个叫做Disruptor就使用到了对齐填充的方式来解决伪共享的问题,Disruptor是英国一家外汇交易公司LMAX开发出来的一个单机版高性能的异步处理框架,类似于一个消息中间件。
  • 使用@Contented注解 在Java8里面提供了@Contented注解,它可以通过缓存行的填充来解决伪共享的问题,被@Contented注解声明的类或者字段,会被加载到独立的缓存行里面。在Java8的ConcurrentHashMap中桶计数器(CounterCell)就使用到了这个注解,在ConcurrentHashMap中,每个桶都是单独用计数器去做计数的,而每个计数器都在随时变化,所以被使用这个注解进行填充缓存行的优化,以此来提升性能。但是这个注解一般只允许在JDK内部使用,如果自己想要使用就得去修改jvm的配置参数 -RestricContentended=false,将这个注解限制取消。一般情况下不推荐这样去干。
  1. 对象的创建过程

在对象创建过程中,JVM首先会去检查是否创建了这个对象,如果有了这个对象,就调用目标对象的构造器初始化类对象,类对象的加载是通过类加载器来完成的,主要就是把一个类加载到内存里面,然后是对目标对象的初始化过程,初始化过程就是把目标对象的静态变量、成员变量、静态代码块进行初始化,当目标类被初始化以后,就可以从常量池里面找到对应的类元信息,且目标对象的大小在类加载完成的时候就已经确定了,因此这个时候就要为新创建的对象根据目标对象的大小去申请内存空间,内存分配的方式有两种,第一种是指针碰撞,第二种是空闲列表,JVM会根据Java堆内存是否规整来决定内存的分配方法,接着JVM会把目标对象里面的普通成员变量赋默认值,接着设置目标对象的对象头,其对象头主要包含了GC分代年龄、类hash值、锁标记等信息,完成了这些步骤,对于JVM来说,新的对象创建完成,但是对于Java来说,才刚刚开始,接下来调用目标对象内部的init方法,初始化成员变量的值、执行构造块,最后调用目标对象的构造方法去完成对象的创建。

  1. ThreadLocal及其实现原理

ThreadLocal是一种隔离机制,他提供了本地线程的存储功能,保证了多线程环境下对共享变量访问的一个安全性,在多线程访问共享变量的场景里面,一般情况下我们是对共享变量加锁去访问,确保在同一个时刻只有一个线程能对这个共享变量的访问和更新,且基于happens-befor的锁监视规则,又能够保证属性修改后对其他线程是可见的。但是它会带来性能上的下降,因此ThreadLocal采用了一种以空间换时间的设计思想,也就是说在每一个线程里面都有一个容器,来存储共享变量的一个副本,然后每个线程只对自己的变量副本来做更新操作,这样既避免了线程的安全问题,又避免了多线程之间锁竞争的开销。ThreadLocal实现的具体原理是在Thread类里面维护了一个成员变量 叫ThreadLocalMap,它是ThreadLocal中的一个内部类,他专门用来存储当前线程共享变量的副本,后续对线程变量副本的操作,都是从这个ThreadLocalMap里面去进行变更,不会影响全局共享变量的值,从而实现共享变量的隔离。但是这里也引发出一个问题,由于线程池中的线程不会被回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象,线程不会回收,Entry对象也不会被回收,从而导致出现内存泄漏的情况,因此在使用完了ThreadLocal之后,需要手动的调用ThreadLocal的remove方法清除当前的Entry对象。

  1. ArrayBlockingQueue的实现原理

阻塞队列是在队列的基础上增加了两个附加的操作,第一个是在队列为空的时候,获取元素的线程会等待队列变为非空,而当队列满了以后,存储元素的线程会等待队列变为可用,基于这一特性,可以非常容易的去实现生产者和消费者的一个模型,也就是说生产者只需要关心数据的生产,而消费者只需要关心数据的消费,所以队列满了,生产者就等待,同样队列空了,消费者就等待。要实现这样一个队列,需要用到两个非常关键的技术:

  • 队列元素的存储
  • 线程的阻塞和唤醒

而ArrayBlockingQueue他是基于数组结构的阻塞队列,也就是说队列元素存储在一个数组结构里面,且数组的长度是有限制的,为了达到循环生产和循环消费的目的,ArrayBlockingQueue用到了一个循环数组,而线程的阻塞和唤醒用到了JUC包里面的ReentrantLock和Condition,Condition相当于一个wait和notify在JUC里面的一个实现。

  1. JDK动态代理为什么只能代理有接口的类

这是由于动态代理本身的机制来决定的,在Java的源码里面,动态代理是通过proxy.newProxyInstance()这个方法来实现的,他需要传入被动态代理的一个接口类,之所以要传入接口而不是一个类,还是取决于JDK动态代理的一个底层实现,JDK动态代理会在程序的运行期间,动态的生成一个代理类$Proxy,这个动态生成的代理类会继承一个java.lang.reflect.Proxy,同时还会去实现被代理类的接口,在Java里面是不支持多继承的,而每一个动态代理类都继承了一个proxy,所以就导致JDK里面的动态代理只能代理接口而不能代理实现类,在动态代理的源码里面,Proxy这个类只是保存了动态代理的一个处理器 InvocationHandler,如果不抽出来,直接设置到$proxy0这个动态代理类里面,也是可以的,如果这么实现,就可以针对每个实现类来做动态代理了,作者之所以这样设计,个人认为有以下几个方面的原因:

  • 基于动态代理本身的使用场景或需求,只是对原始实现的一个拦截,然后去做一些功能的增强或者扩展,而实际的开发模式都是基于接口来开发的,因此基于接口来实现动态代理,从需求和场景来说都是吻合的,当然可能确实存在一些没有实现接口的一些类也需要实现动态代理,那么这个时候JDK显然是无法满足的。
  • 在Java里面类的技能设计,更多的是考虑到共性能力的抽象,从而提高代码的复用性和扩展性,而动态代理封装了动态代理类的生成的抽象逻辑,以及判断一个类是否是动态代理类,以及invocationHandler的持有等,因此把这些抽象的公共逻辑放在proxy这个父类里面,它也是一个比较正常的设计思路,因此总的来说,这个设计在实际应用中还是比较贴合实际使用场景的。

如果要选择去针对普通类来做动态代理,spring也提供了cglib这样的一个组件,他会生成一个被动态代理类的子类,子类重写所有父类非final修饰的方法,在子类中拦截所有父类方法的调用,从而去实现动态代理。

  1. 死锁发生的原因?怎么避免

死锁就是两个或两个以上的线程在执行过程中去争夺同一个共享资源造成的相互等待的现象,如果没有外部的干预,线程会一直阻塞,无法往下执行,这样一种一直处于相互阻塞等待资源的线程,就是死锁。导致死锁的条件有四个,也就是说这四个条件同时满足,才会触发死锁的发生:

  • 互斥条件
  • 请求和保持条件
  • 不可抢占条件
  • 循环等待条件 线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待

导致死锁之后,只能通过人工干预来解决,通过重启或者kill掉服务,因此只能是在写代码的时候就去规避可能出现死锁的问题,而按照触发死锁的四个必要条件,只需要破坏其中的一个条件,就可以避免死锁,但是互斥条件是没有办法被破坏的,因为他是互斥锁的基本约束,而其他的三个条件,都有办法来破坏,比如请求和保持这个条件,我们可以一次性申请所有资源,这样就不存在锁等待了;对于不可抢占条件,在占用资源进一步申请其他资源的时候,如果申请不到,可以主动的去释放他占有的资源,这样不可抢占这个条件就被破坏掉了;对于循环等待这个条件,可以按序申请资源来预防,因为资源是有线性顺序的,申请的时候可以先申请资源序号小的,然后再申请资源序号大的,这样线性化之后,就不存在循环等待了。基于以上的方案,就可以满足避免死锁了。

  1. CAS机制

CAS是Java Unsafe类里面的一个方法,全称叫compareAndSwep,比较并交换,他的主要功能是在多线程的情况下,对共享变量保证原子性的操作,CAS在hotspot中的实现:

他调用了底层 unsafe C++的 Unsafe_compareandswapint方法,该方法最终调用了 Atomit 的cmpxchg方法,首先判断是否是mp (multy processor 多核),如果是,则加上lock指令,实际上他的汇编指令,还是一个lock悲观锁,直接进行总线锁。

CAS是在用户态进行的,所以理论上来说他比重量级锁的执行速度要快,因为不用向OS(操作系统)申请资源,但是 并不是乐观锁就一定比悲观锁快 ,乐观锁是在自旋中,不停的循环,直到可进行,此时的线程是runing状态,所以他需要消耗CPU资源。而悲观锁是进入队列中等待,不消耗资源,此时线程状态是属于 waiting 状态,阻塞中。所以在线程数比较少、执行时间短的情况下,优先使用用乐观锁(自旋锁),反之则使用重量级锁。

CAS的 ABA问题 在多线程情况下,一个属性值被某一个线程修改了,其中一个线程因为是并发发生,导致被修改的属性又还原成原来的属性了。 A----->B------>A,通过加版本号来解决ABA问题,与SQL的乐观锁一个意思。

  1. 线程池中,如何知道一个线程已经执行完成

在线程池的内部,把一个任务丢给线程池去执行的时候,线程池会调度工作线程来执行这个任务的run方法,当run方法正常执行结束以后,也就意味这个任务完成了。所以线程池是通过同步调用任务的run方法,等待run方法执行完成返回后,再去统计任务的完成数量。

如果想在线程池的外部获取线程池内部任务的执行状态,有几种方法可以实现:

  • 线程池提供了一个isTerminated()方法,可以去判断线程池的运行状态,我们可以循环去判断isTemernated()方法的返回值来判断线程的执行状态,一旦线程池的运行状态是temernated,意味着线程池中的所有任务都已经执行完成了,但是获取这个状态有一个前提,就是程序中需要主动的调用shutdown()方法,但是在实际工作中,一般不会主动去关闭线程池,因此这个方法在灵活性方面就不太适用。
  • 在线程池中提供了一个submit()方法,他提供了一个futuer的返回值,可以通过futuer.get()方法,获得任务的执行结果,当线程池中的任务没有执行完成之前,futuer.get()会一直阻塞,直到任务执行结束,因此,只要future.get()方法正常返回,就意味着传入线程池中的任务就已经执行完成了。
  • 可以引入CountDownLatch计数器,他可以通过初始化一个指定的数值去进行倒计时,这个数值和要执行的任务数量一致,他提供了两个方法:await()阻塞线程和countdown()去进行倒计时,一旦倒计时归零,所有阻塞在await()方法的线程都会被释放。

总结一下:不管是线程池内部或者是外部,想要知道线程是否执行结束,我们都需要获取线程执行结束后的状态,而线程本身是没有返回值的,所以只能通过阻塞、唤醒的方式来实现,futuer.get()、countDownLatch都是基于这一个原理去实现的。

  1. 什么叫阻塞队列的有界和无界

阻塞队列是一种特殊的队列,他在普通队列的基础上增加了两个附加的功能:

  • 当队列为空的时候,获取队列中元素的消费者线程会被阻塞,同时会唤醒生产者线程;
  • 当队列满了的时候,去添加元素的生产者线程会被阻塞,同时唤醒消费者线程。

其中,阻塞队列中能够容纳的个数通常是有限的,通常在数组初始化的时候,可以设置一个固定的数组长度,这个基于数组的元素个数,这种就是有界队列。而无界队列,就是没有设置元素个数的队列,但是他并不是真的对元素个数毫无限制,而是他的元素存储量很大,例如LinkedBlockingQueue中默认的就是Integer.MAX_VALUE。由于元素空间足够大,因此在多线程并发的情况下,也会存在一定的风险,线程池中几乎可以毫无限制的增加元素,容易导致内存溢出的问题。阻塞队列在生产者、消费者模型中使用的比列是比较高的,他比较适合在一些异步化的任务消费场景以及做并发流量缓冲类的场景中。在很多开源组件中就大量用到了这种模型,比如在zookeeper中,就大量的用到了阻塞队列来实现生产者和消费者模型。

  1. 什么是AQS

AbstractQueueSynchronized

AQS是一个多线程同步器,他是JUC包中多个组件的底层实现,例如lock、countDownLatch、Semaphor都用到了AQS。从本质上来说,AQS提供了两种锁机制,分别是排他锁、共享锁。所谓排它锁,就是多个线程去竞争一个共享资源的时候,同一时刻只能有一个线程持有锁,其他线程等待的一个过程,比如Lock中的ReentrantLock,他的底层就使用到了AQS的排它锁功能。共享锁,也称为读锁,表示锁资源在同一时刻允许多个线程持有,比如countDownLatch、Semaphore都用到了AQS中的共享锁功能,AQS作为互斥锁,他的设计中需要解决三个核心的问题:

  • 互斥变量设计以及多个线程同时更新这个互斥变量的时候,如何保证这个互斥变量的安全性
  • 未竞争到锁资源的线程的等待以及竞争到锁资源的线程的唤醒
  • 锁竞争的公平性和非公平性

AQS内部维护了一个用volatile修饰的int 变量 state来表示锁状态,0 表示无锁,大于或等于1表示已有锁,则其他线程必须加入等待队列,一个线程来获取锁的时候,首先判断state值是否等于0,如果是,表示当前为无锁状态,则使用CAS把当前这个state变更为1,如果更新成功,当前线程竞争锁成功,未获取到锁的线程,通过unsafe类中的park方法进行阻塞,把阻塞的线程按照先进先出的原则加入到一个双向链表的结构中,当获得锁资源的线程被释放后,或从双向链表的头部唤醒下一个等待的线程,再去竞争锁。关于锁的公平和非公平的问题,在公平锁的情况下,AQS的处理方式是,在竞争锁资源的时候,需要去判断双向链表中是否有阻塞的线程,如果有则去等待;而非公平锁的处理方式是,不管双向链表中是否存在等待锁的线程,他都会直接去尝试竞争锁。

  1. Lock和Synchronized的区别
  • 从功能角度来说,他们都是用来解决线程安全问题的一个工具,Lock 比 Synchronized 的 同 步 操 作 更 精 细( 因 为 可 以 像 普 通 对 象 一 样 使 用 ) , 甚 至 实 现 Synchronized 没 有 的高 级 功 能 , 如 :
  1. 等 待 可 中 断 : 当 持 有 锁 的 线 程 长 期 不 释 放 锁 的 时 候 , 正 在 等 待 的 线 程 可以 选 择 放 弃 等 待 , 对 处 理 执 行 时 间 非 常 长 的 同 步 块 很 有 用 。
  2. 提供了基于非阻塞竞争锁的方法 tryLock(): trylock 即尝试加锁,在限定的时间内没有获取到锁,如 果 时 间 到 了 仍 然无 法 获 取 则 返 回 ,即可根据实际业务逻辑进行下一步操作,而 Synchronized 没有获取到锁,则会进入 wait 阻塞状态 。
  3. 可 以 判 断 是 否 有 线 程 在 排 队 等 待 获 取 锁 。
  4. 可 以 响 应 中 断 请 求 : lockinterupptibly 监听锁打断操作(interrupt),与 Synchronized 不 同 , 当 获 取 到 锁 的 线 程 被 中断 时 , 能 够 响 应 中 断 , 中 断 异 常 将 会 被 抛 出 , 同 时 锁 会 被 释 放 。
  5. lock提供了公平锁和非公平锁的实现。
  6. 从 锁 释 放 角 度 , Synchronized 在 JVM 层 面 上 实 现 的 , 不 但 可 以 通 过一 些 监 控 工 具 监 控 Synchronized 的 锁 定 , 而 且 在 代 码 执 行 出 现 异 常时 , JVM 会 自 动 释 放 锁 定 ; 但 是 使 用 Lock 则 不 行 , Lock 是 通 过 代码 实 现 的 , 要 保 证 锁一 定 会 被 释 放 , 就 必 须 将 unLock() 放 到finally{} 中 。从 性 能 角 度 , Synchronized 早 期 实 现 比 较 低 效 , 对 比ReentrantLock, 大 多 数 场 景 性 能 都 相 差 较 大 。但 是 在 Java 6 中 对 其 进 行 了 非 常 多 的 改 进 , 在 竞 争 不 激 烈 时 ,Synchronized 的 性 能 要 优 于 ReetrantLock; 在 高 竞 争 情 况 下 ,Synchronized 的 性 能 会 下 降 几 十 倍 , 但 是 ReetrantLock 的 性 能 能 维持 常 态 。
  • 从特性来看,Synchronized是Java中的同步关键字,而Lock是JUC包里面提供的一个接口,而这个接口有很多的实现类,其中就包括了ReentrantLock这样一个可重入锁的实现。Synchronized在代码里面通常有两种写法,第一种写法是直接修饰在方法上面,对整个方法进行加锁阻塞,另外一种是修饰在对应的代码块上,他可以更好的控制锁粒度,通过锁对象的生命周期来控制锁的范围,例如锁对象是静态对象或者是类对象,那么这个锁就是一个全局锁,跟随应用的生命周期,如果锁对象是一个普通实例对象,那么这个锁的范围就取决于这个实例的生命周期。而Lock锁的粒度是通过他提供的lock方法和 unLock方法来控制的,锁的作用域取决于lock实例的生命周期,因此lock的灵活性更高,lock可以自主决定什么时候加锁,什么时候释放锁。
  • 从性能上来看,两者的性能实际上相差不大,Synchronized在JDK1.6版本之后,开始提供了偏向锁、轻量级锁、重量级锁的锁升级方式来实现锁的优化,而lock则基于CAS的方式去实现性能的优化
  1. fail-safe 与 fail-fast机制

fail-safe机制与fail-fast机制 是多线程并发操作集合时的一种失败处理机制

fail-fast 快速失败 表示当集合在遍历的过程中,一旦发现容器中的数据被修改,立刻抛出ConcurrentmodificationException异常,从而导致遍历失败

fail-safe 安全失败,当容器中的数据发生修改时,不会抛出异常。原因是采用安全失败机制的集合容器,在遍历时不会直接访问原来的集合,而是先吧原来的集合复制一遍,然后再对复制的集合进行遍历,因此原集合的修改并不会对复制后的集合产生影响。

  1. HashMap如何解决hash冲突

解决这个问题,首先需要了解hash算法和hash表,hash算法就是把任意长度的输入通过散列算法,变成固定长度的输出,这个输出结果就是散列值;hash表也叫散列表,他是通过key直接访问到内存存储位置的数据结构,他的具体实现原理是通过把key映射到表中的某个位置,来获取整个位置的数据,从而加快数据的查找。

所谓hash冲突是由于被计算的属性无限的,而计算后得到的结果范围有限的,所以总会存在不同的数据经过计算后得到相同的结果,此时就会出现hash冲突,通常解决hash冲突的方法有四种:

  • 开放寻址法:从发生冲突的位置开始,按照线性寻找,从hash表中去找到一个空闲位置,然后把发生冲突的元素存入这个位置,而Java中的ThreadLocal就用到了这种线性寻址的方法来解决hash冲突。
  • 链式寻址法:把存在hash冲突的key,以单向链表的方式来进行存储,hashMap就用到了这种方法。
  • 再hash法:把存在hash冲突的这个key,再次进行hash计算,一直运算,直到不再产生冲突为止。
  • 建立公共溢出区:就是把hash表分为基本表和溢出表两个部分,凡是存在冲突的元素,一律放到溢出表中。

在JDK1.8版本中,hashMap是通过链表 + 红黑树来解决hash冲突的,红黑树是为了优化hash表的链表过长,导致时间复杂度增加的问题。当链表的长度大于8,且hash表的容量大于64的时候,就会自动转换为红黑树。

  1. ConcurrentHashMap的底层实现原理

在Java1.7版本中,ConcurrentHashMap由数组 + Segment + 分段锁实现,其内部分为一个一个的Segment 数组,Segment继承了 ReentrantLock来进行加锁。通过每次锁住一个Segment来降低锁粒度,且保证了每个Segment内的操作都是线程安全的。从而实现全局线程安全。但是这样的设计有一个缺陷就是每次需要通过hash确认位置时,需要进行两次的hash计算才能定位到当前key所在的位置。

  • 通过hash值和Segment数组长度 -1 进行位运算,确认当前的key在哪个Segment。
  • 再次通过 hash值和table长度 -1 来进行位运算,确认其所在的桶。

基于以上缺陷,在Java1.8中对其进行了优化,取消了分段锁,以CAS来代替,数据结构也变成了数组 + 单向链表 + 红黑树的方式来实现,当链表长度大于8,且数组长度大于64的时候,此时再增加元素,单向链表就会转换为了红黑树的方式来降低时间复杂度,因此一旦链表的长度小于8,红黑树又会退化为单向链表。扩容也采用来一种分而治之的思想来提升扩容效率,他采用了多线程并发来扩容,简单来说就是多个线程对原始数组进行分片,分片之后,每个线程负责对一部分数据迁移,从而提升扩容过程中的迁移效率。在Java1.8中,除了线程安全方面与HashMap不一样外,其他的思想都差不多一致。

在ConcurrentHashMap中,是不允许 null的key存在的,因为开发者认为这样会引起歧义,最终无法确认是本来就是null呢 还是这个key 根本就不存在引起的null 呢,因此作者就直接取消了null 的key 和 value。

  1. GC如何判断对象可以被回收

在虚拟机中有两种处理方式来发现定位垃圾:

  • 引用计数器:每个对象都有一个引用计数属性,新增一个引用时计数 +1,释放时 -1,当计数器为0时,表示可以回收,但是引用计数器存在一个问题,就是循环引用时,存在循环引用的对象又确实没有被使用了,这就会导致这几个对象永远无法被回收。
  • 根可达算法:从GC Root开始向下搜索,搜索每一个引用链,当一个对象没有任何引用链存在时,表示此对象不可用,可以回收。GC Root包含 JVM栈、本地方法栈、运行时常量池、静态变量等。

在可达性算法中,不可达的对象并不是立即死亡的,他有一次复活的机会,对象被标记死亡,至少要经历两次标记过程:第一次是经过可达性分析发现没有与GC Root相连接的引用链,第二次是由虚拟机自动建立的Finalizer队列中判断是否需要执行finalize()方法。当对象变成不可达时,GC会判断该对象是否重写了finalize方法,若未重写,则直接回收,否则,若对象未执行过finalize方法,将其放入F-queue队列,由低优先级线程执行该队列中对象的finalize方法,然后再次判断该对象是否可达,若不可达,则回收,否则复活对象。

  1. JVM内存模型

JVM Runtime Data Area (JVM运行时数据区)

运行时数据区模型:

线程独占的部分,他们跟随线程的生命周期,线程启动则存在,线程销毁则一并销毁。线程共享部分,则跟随JVM的生命周期。

  1. programCounter (程序计数器/PC寄存器)(线程私有/独占)

每一个线程都有一个自己的程序计数器,他是为了避免线程频繁的进行切换。程序计数器,占用一块较小的内存空间,表示当前线程正在执行的指令,他存储的是正在执行的这个执行的字节码地址,他就相当于一个死循环:

// 伪代码 while (programCounter != undefined) { 执行指令; }

  1. JVM stack (Java虚拟机栈)

用于描述Java中方法的执行过程,包括方法调用、返回值等,是线程私有的一块内存区域。当执行或调用一个方法时,都会创建一个栈帧,用于存储局部变量和部分计算结果,栈帧中包含了以下几部分内容:

  • 局部变量表 (Local Variable Table)

保存方法参数和该方法内部使用到的非静态局部变量,其作用域仅在这个方法内,方法执行完成,结束其生命周期。

  • 操作数栈(Operand stack)

用于方法体中操作数运算时的入栈与出栈,代表的是运算过程。

public static void main(String[] args) { int x = 8; x = x++; System.out.println(x); } //测试x=x++ bipush 8 //把常量8压栈 istore_1 //常量8出栈并存储到局部变量表下标为1的位置,也就是把8赋值给x (为什么x的下表为1,因为入参args也在局部变量表) 注:前两条指令代表int x=8执行完成,虽然赋值操作是两条指令,但是由于8压栈是不能被修改的,所以总体也是原子性的。 iload_1 //把局部变量表下标为1的位置的变量值拿出来压栈 iinc 1 by 1 //局部变量表下标为1的位置执行自增1的操作 注:此处操作的不是压入栈中的数据,而是局部变量表中的数据,也就是局部变量表中的x从8变为了9,但是上一步已压栈的还是8 istore_1 //把栈中的数据8重新存到局部变量表中下标为1的位置,这个时候x又从9变为了8 getstatic #2 <java/lang/System.out> //调用System.out进行输出 iload_1 invokevirtual #3 <java/io/PrintStream.println> return

public static void main(String[] args) { int x = 8; x = ++x; System.out.println(x); } //测试x=++x bipush 8 istore_1 注意:这里少了一条iload_1指令,也就是没有将8压栈 iinc 1 by 1 //这里同样的是把局部变量表下标为1的位置执行自增1的操作 iload_1 //这里取出来的数据是自增之后的值9 注:所以x=++x最后执行的结果是9 istore_1 getstatic #2 <java/lang/System.out> iload_1 invokevirtual #3 <java/io/PrintStream.println> return

  • 动态链接(Dynamic Linking)

指向常量池,用于标识变量、方法名、类名等引用符号,Java中的类经过加载后会将符号引用进行解析,然后存于常量池中。

  • 返回值地址(Return Address)

被调用方法执行结束后,返回值存放的地址以及调用方法应该继续执行的指令位置。

  1. 本地方法栈(Native Method Stack)

和虚拟机栈类似,只不过虚拟机栈用于执行Java本身的方法,而本地方法栈用于执行Native方法(非Java代码实现),是线程私有内存空间。

  1. 堆(Heap)(线程共享)

所有线程共享的内存空间,在JVM中只有一个堆存在,所有对象实例以及数组都在堆上分配内存,但是随着JIT(just in time 及时编译)、栈上分配、标量替换等技术的发展,使得在堆上分配变得不那么绝对,只能在server模式下才能启用逃逸分析。

逃逸分析:逃逸分析的目的是判断对象的作用域是否有可能逃出函数体;

标量替换:允许将对象打散分配到栈上,比如若一个对象拥有两个字段,会将这两个字段是做局部变量进行分配。

当堆中没有内存完成实例分配,并且堆无法再扩展的时候,会抛出OOM

  1. 方法区(Permanet Space/Method Area Java1.8)(线程共享)

在Java1.8之前,叫Permanet Space,也叫永久代,从Java1.8开始,叫做方法区。之所以有此区别,是因为在Java1.8之前,JVM以永久代来实现方法区,从而JVM可以像管理堆区一样管理这部分区域,不需要专门为这部分单独设计垃圾回收机制。而从1.8开始,JVM便将运行时常量池从永久代移除了。

在方法区,存储了每个类的类信息(类名称、方法信息、字段信息)、静态变量、常量以及编译后的代码等。在Class文件中,除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符合引用。

在方法区还有一块内存,叫运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来,但是并不是只有Class文件常量池中的内容才能进入运行时常量池,在运行期间也可以将新的常量放入运行时常量池,例如String的intern方法。

可能会引起OOM的几种情况:

  • 方法区递归调用,可能会导致内存溢出
  • 当常量池无法再申请到内存时
  1. 线程之间如何进行通讯

同一个进程内的线程通讯,一般基于共享内存来通讯,这里需要考虑线程并发的问题,跨进程或者跨机器节点的,可以使用网络来通讯。

  1. 为什么在 Java8 之后,用元空间替代了永久代

永久代,也叫方法区,方法区是JVM在运行时分配的区域,方法区是一个共享区域,主要存储类信息、常量、静态变量、及时编译器编译后的代码等数据,在过去自定义类加载器使用不普遍的情况下,类几乎是静态的并且很少被卸载和回收,因此方法区也就被看成是一个永久的区域,这也就是永久代的含义。另外由于类作为JVM实现的一部分,他们不由程序来创建,所以为了和堆区分开,就给了方法区这样一个名字来存储信息。在Java8之前,方法区是属于堆的一个逻辑部分,因此永久代和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,就会触发垃圾回收,这两个区都会进行垃圾回收。这就增大了触发OOM的机率,同时由于后续自定义类加载器使用的情况比较多,比如tomcat,这就使得永久代中原来不会变的数据,现在会产生变动了。基于以上两点考虑,从Java8开始,将永久代更名为元空间,同时将元空间从堆剥离,不再是组成堆的 一部分,并且它是存在于本地内存中的。

  1. 什么是可重入锁以及他的作用

可重入是多线程并发编程里面的一个重要的概念,意思就是某个在运行的线程或者代码,因为抢占资源或者中断,导致这个执行过程被打断,当其他资源执行完成,该任务重新获得资源执行,其执行结果不会发生变化,那么他就是可重入的。锁的可重入性主要是为了避免死锁的发生。

  1. CPU 使用率高,如何定位问题

首先使用top命令找到占用 CPU 资源最高的进程,然后使用top -Hp命令 + 进程号,显示该进程内所有的线程,此时界面中显示的 Pid就是线程号,接下来使用jstack命令来查看对应的线程执行情况,但是top命令得到的线程pid是一个十进制的,线程在系统中运行的是 16 进制的,因此需要将得到的pid转换成 16 进制,使用printf '%x\n' 线程号来转换,jstack 默认有三个参数:

  • -F:当线程挂起时,使用jstack -l pid是不会打印堆栈信息的,使用此参数可以强制输出线程堆栈,但是会造成应用短时间内的停止;
  • -l:打印的信息除了堆栈外,还会显示锁的附加信息;
  • -m:同时输出Java 和 C/C++的堆栈信息

这里我们使用jstack -l pid的命令得到线程信息,可以使用jstack -l pid >dump文件的方式,输出线程文本,到客户端查看,在输出的内容中,找到对应的线程号,然后即可定位其故障问题。

  1. 如何定位内存故障

有两种方案:

  • 使用jmap命令:先使用jps命令找到对应应用程序的的进程 ID,然后使用jmap -histo:live pid | head -10命令,找出该进程中占用前十的对象,此时可以看到占用内存最高的对象,此时即可去查找到对应的对象去处理;如果还不够,可以使用 jmap -dump 命令来导出堆文件,使用jdk自带的jvisualvm工具查看,即可排查出对应的问题,但是在线上不推荐使用jmap命令来转储堆文件,因为他会造成应用的暂停;
  • 使用arthas:在目标服务器上运行arthas工具,进入对应的应用进程,使用 dashboard 命令,打开控制面板查看当前异常信息;使用heapdump命令导出堆文件,然后使用jvisualvm工具查看,排查问题
  1. Synchronized锁

一、锁升级过程

锁升级并不是一开始就有的,在JDK1.6之前,monitor 的实现完全依赖于底层操作系统的互斥锁来实现,每次使用锁的时候都需要向系统申请,从用户态切换为内核态,效率低,且占用资源,因此从1.6开始,有了锁升级的功能,其升级流程如下:

在对象刚刚创建之初,对象是无锁的,他的锁标记状态为001,JVM默认在启动4S后给对象加锁,此时状态为101,因为此时还没有记录线程指针,因此称为匿名偏向锁。可以通过命令:-XX: BiasedLockingStartupDelay 设置加锁时间。因为JVM在启动的时候,默认会启动一些工作线程,这些线程就会有锁竞争,为了降低锁竞争,提升效率,因此有了延迟启动偏向锁的的操作。

严格来讲,偏向锁并不是一把锁,而是一个标记,仅仅只是标记了该对象偏向某个线程。当出现两个或两个以上的线程竞争锁的时候,升级为轻量级锁(自旋锁),他会在自己的线程栈中生成自己独立的LockRecord(锁记录,简称 LR),抢线程的时候使用CAS的方式将LR的指针更新到markword,成功刷新进去,则表示抢到了锁,否则继续自旋,直到抢到锁位置。

锁自旋的过程中,是需要占用资源的,因此JVM对自旋锁做了一定的控制,当自旋达到一定的阈值以后,升级为重量级锁,在JDK1.6之前默认为10次,可以通过preSpinLock来设置自旋次数,在进入新版本以后,JVM对此做了优化,叫自适应自旋,JVM自动根据实际情况(比如CPU资源、任务数量等)做出调整,升级为重量级锁。

锁的原理:

通过字节码发现,在被synchornized修饰的代码块前后,出现了moniterenter 和monitorexit的字节码指令,锁的本质就是被这两个指令的一个refrence类型的参数。在虚拟机执行到moniterenter的时候,首先尝试获取对象的锁,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁计数器 +1,当moniterexit执行行,将锁计数器 -1,当计数器为0时,锁就被释放了。此图中有两个monitorexit,最后一个monitorexit 是为了确保发生异常时,能够正常释放锁,防止死锁而设置的。

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

推荐阅读
  Olt1rl96HKat   2023年12月22日   23   0   0 动态代理动态代理
  fBdBA9tXzLZY   2023年12月22日   46   0   0 线程池线程池
Tgkpp50AtIpa