您现在的位置是: 首页  >  Java
  • Java编程中List、Integer[]、int[]之间优雅的相互转换

    Java编程中List、Integer[]、int[]之间优雅的相互转换

    有时候List和数组int[]转换很麻烦。List和String[]也同理。难道每次非得写一个循环遍历吗?其实一步就可以搞定。本文涉及到一些Java8的特性。如果没有接触过就先学会怎么用,然后再细细研究。1、int[]转List1.Arrays.stream(arr)可以替换成IntStream.of(arr)。2.使用Arrays.stream将int[]转换成IntStream。3.使用IntStream中的boxed()装箱。将IntStream转换成Stream。4.使用Stream的collect(),将Stream转换成List,因此正是List2、int[]转Integer[]前两步同上,此时是Stream。然后使用Stream的toArray,传入IntFunction<A[]>generator。这样就可以返回Integer数组。不然默认是Object[]。3、Integer[]转List这个就很简单了,通过Arrays类里的asList方法将数组装换为List。4、Integer[]转int[]map的意思是把每一个元素进行同样的操作。mapToInt的意思是把每一个元素转换为int。mapToInt(Integer::intValue)方法返回的是IntStream。5、List转int[]想要转换成int[]类型,就得先转成IntStream。这里就通过mapToInt()把Stream调用Integer::valueOf来转成IntStream.而IntStream中默认toArray()转成int[]。6、List转Integer[]这个也很简单,方法里的参数是一个数组,所以要规定长度。也有无参的方法,但是要进行转型,所以不推荐使用。参考【1】workscart.Java中List,Integer[],int[]的相互转换.CSDN【1】君莫笑(๑˙ー˙๑).基于jdk1.8的List<Integer>、int[]、Integer[]的相互转换简洁写法及解释.CSDN

    LoveIT 2020-11-23
    Java基础
  • Java对HashMap按key排序和按value排序

    Java对HashMap按key排序和按value排序

    HashMap的值是没有顺序的,它是按照key的HashCode来实现的。对于这个无序的HashMap我们要怎么来实现排序呢?(TreeMap类似)一、按key进行排序对Key进行排序大致步骤如下:(1)为了提供一个对Key排序普遍适用的方法,我们使用泛型,并且由于要对K进行排序,所以我们需要限定K所代表的类实现了Comparable接口(2)首先需要得到HashMap中的包含映射关系的视图(entrySet)(3)将entrySet转换为List,然后重写比较器对list中的entry进行排序即可。这里可以使用List.sort(comparator),也可以使用Collections.sort(list,comparator)(4)如果需要返回一个有序的Map,可以把排序后的按照排序后的List将entry中的元素添加到LinkedHashMap中返回即可。代码实现测试:执行main方法,得到如下输出:Java源码中对Map的key排序的支持其实在Java的Map接口的源码中已经提供了对Key和Map进行排序的方法:comparingByKeycomparingByValuecomparingByKey(Comparator<?superK>cmp)comparingByValue(Comparator<?superV>cmp)这几个方法都只是返回一个对应的Comparator,我们拿到这个Comparator就可以对Map进行排序操作了。下面是一个简单使用示例:二、按value进行排序弄明白了对key的排序之后,对value的排序也就水到渠成了,不同的是现在我们需要限定V所代表的类实现了Comparable接口,下面是简单实现:测试运行main方法,输出如下结果:同理,对Map的value排序也可以直接使用Map接口中提供的几个方法,这里不再尝试,有兴趣的小伙伴可以自行尝试。这里有必要在强调的是,我的例子中的Map的key和value的类型是String、Integer这些Java内部提供的类,这些类都是实现了Comparable接口的,所以程序中可以正常使用,但是如果你的Key或value是一个自定义的类型,比如Student,Person等,如果要对这些类型排序的话,必须实现Comparable接口,不然就会出错。参考【1】花伦同学ko.JAVA对Map里的value值进行排序(升序或者降序).CSDN【2】xHibiki.JavaHashMap按key排序和按value排序的两种简便方法.CSDN

    LoveIT 2020-11-05
    Java集合与容器
  • 深入理解Java内存模型

    深入理解Java内存模型

    注意这里是Java内存模型,不是Java内存结构,这两个就不是在同一级别上的东东,有些人会把Java内存模型误解为Java内存结构,然后在面试问到的时候很自信的答到堆,栈,GC垃圾回收,最后和面试官想问的问题相差甚远。本文我们就来学习一下JMM有关的基础知识吧!思维导图什么是JMM?JMM就是JavaMemoryModel的缩写,中文名即Java内存模型。因为在不同的硬件生产商和不同的操作系统下,内存的访问有一定的差异,所以会造成相同的代码运行在不同的系统上会出现各种问题。所以java内存模型(JMM)屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。Java内存模型将内存分为主内存和工作内存两个部分,并且规定所有的变量都存储在主内存中,包括实例变量、静态变量,但是不包括局部变量和方法参数。每个线程都有自己的工作内存,线程工作内存保存了该线程用到的变量和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写内存中的变量。不同的线程之间也无法访问对方工作内存中的变量。线程之间变量值的传递均需要通过主内存来完成。如果听起来抽象的话,我可以画张图给你看看,会直观一点:每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是Java内存模型定义的线程基本工作方式。JMM定义了什么?这个简单,整个Java内存模型实际上是围绕着三个特征建立起来的。分别是:原子性,可见性,有序性。这三个特征可谓是整个Java并发的基础。原子性原子性指的是一个操作是不可分割,不可中断的,一个线程在执行时不会被其他线程干扰。面试官拿笔写了段代码,下面这几句代码能保证原子性吗?第一句是基本类型赋值操作,必定是原子性操作。第二句先读取i的值,再赋值到j,两步操作,不能保证原子性。第三和第四句其实是等效的,先读取i的值,再+1,最后赋值到i,三步操作了,不能保证原子性。JMM只能保证基本的原子性,如果要保证一个代码块的原子性,提供了monitorenter和moniterexit两个字节码指令,也就是synchronized关键字。因此在synchronized块之间的操作都是原子性的。可见性可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。Java是利用volatile关键字来提供可见性的。当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。除了volatile关键字之外,final和synchronized也能实现可见性。synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象为初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。有序性在Java中,可以使用synchronized或者volatile保证多线程之间操作的有序性。实现原理有些区别:volatile关键字是使用内存屏障达到禁止指令重排序,以保证有序性。synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。JMM的八种内存交互操作?好的,面试官,内存交互操作有8种,我画张图给你看吧:lock(锁定),作用于主内存中的变量,把变量标识为线程独占的状态。read(读取),作用于主内存的变量,把变量的值从主内存传输到线程的工作内存中,以便下一步的load操作使用。load(加载),作用于工作内存的变量,把read操作主存的变量放入到工作内存的变量副本中。use(使用),作用于工作内存的变量,把工作内存中的变量传输到执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。assign(赋值),作用于工作内存的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。store(存储),作用于工作内存的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。我再补充一下JMM对8种内存交互操作制定的规则吧:不允许read、load、store、write操作之一单独出现,也就是read操作后必须load,store操作后必须write。不允许线程丢弃他最近的assign操作,即工作内存中的变量数据改变了之后,必须告知主存。不允许线程将没有assign的数据从工作内存同步到主内存。一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过load和assign操作。一个变量同一时间只能有一个线程对其进行lock操作。多次lock之后,必须执行相同次数unlock才可以解锁。如果对一个变量进行lock操作,会清空所有工作内存中此变量的值。在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。一个线程对一个变量进行unlock操作之前,必须先把此变量同步回主内存。volatile关键字详解?很多并发编程都使用了volatile关键字,主要的作用包括两点:保证线程间变量的可见性。禁止CPU进行指令重排序。1、保证线程间变量的可见性用volatile修饰一个变量,可以保证变量在多个线程之间的可见性。即当一个线程修改了该变量的值,其他线程可以立即感知到这个修改。volatile保证可见性的流程大概就是这个一个过程:volatile一定能保证线程安全吗?先说结论吧,volatile不能一定能保证线程安全。怎么证明呢,我们看下面一段代码的运行结果就知道了:为什么volatile不能保证线程安全?很简单呀,可见性不能保证操作的原子性,前面说过了count++不是原子性操作,会当做三步,先读取count的值,然后+1,最后赋值回去count变量。需要保证线程安全的话,需要使用synchronized关键字或者lock锁,给count++这段代码上锁或者直接使用原子类。2、禁止CPU进行指令重排序现代CPU在执行一条指令的分为5个阶段:取指令-->指令译码-->执行指令-->内存访问-->数据写回。为了提高指令执行的吞吐量,现代CPU都支持多级指令流水线(一般就是五级指令流水线),就是可以在一个时钟周期内同时对5个指令执行不同阶段的操作,本质上,流水线方式不能缩短一条指令执行的时间,但它提高了CPU的吞吐率。因此,在流水线模式下,为了提高CPU的指令执行效率以及CPU的吞吐率,就会对指令重新排序,让指令能够尽量不间断的送给CPU处理,当然重排序的前提是重排序前后执行的结果不变。这里就要讲一下as-if-serial语义了,语义规定:不管怎么重排序,(单线程)程序的执行结果不能被改变。重排序的种类分为三种,分别是:编译器重排序,指令级并行的重排序,内存系统重排序。整个过程如下所示:指令重排序在单线程是没有问题的,不会影响执行结果,而且还提高了性能。但是在多线程的环境下就不能保证一定不会影响执行结果了。所以在多线程环境下,就需要禁止指令重排序。volatile关键字禁止指令重排序有两层意思:当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面操作可见,在其后面的操作肯定还没有进行。在进行指令优化时,不能将在对volatile变量访问的语句放在器后面执行,也不能把volatile变量后面的语句放到其掐面执行。举个例子说明:变量a,b是非volatile修饰的变量,k则使用volatile修饰。所以语句3不能放在语句1、2前,也不能放在语句4、5后。但是语句1、2的顺序是不能保证的,同理,语句4、5也不能保证顺序。并且,执行到语句3的时候,语句1,2是肯定执行完毕的,而且语句1,2的执行结果对于语句3,4,5是可见的。volatile禁止指令重排序的原理是什么volatile禁止指令重排序的原理就是通过内存屏障实现的。Java编译器会在生成指令的时候在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。JMM会针对编译器制定volatile重排序规则表,如下:其中“NO”表示禁止指令重排序,为了实现这一语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。需要注意的是:volatile写是在前面和后面分别插入内存屏障,volatile读操作是在后面插入两个内存屏障。volatile写在每一个volatile写操作之前插入StoreStore屏障,在其之后插入StoreLoad屏障。StoreStore屏障:对于这样的语句Store1,StoreStore,Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。StoreLoad屏障:对于这样的语句Store1,StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。volatile读LoadLoad屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。LoadStore屏障:对于这样的语句Load1,LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。其实这里的内存屏障就是volatile的底层实现lock前缀指令提供内存屏障(也叫内存栅栏),内存屏障会提供3个功能:(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(2)它会强制将对缓存的修改操作立即写入主存;(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。参考【1】阿里云云栖号.今日头条

    LoveIT 2020-09-25
    Java多线程与高并发
  • 玩转Java8 Stream之IntStream

    玩转Java8 Stream之IntStream

    IntStream详解IntStream是特殊的Stream,但有一些操作符是IntStream独有的;话不多说,开始玩转IntStream吧。理论讲解构造IntStreamIntStream这个接口里提供了如下方法用于构造一个流:操作IntStream过滤操作filter()//根据条件过滤元素转换操作拍扁操作去重操作排序操作查看元素限流操作跳过操作遍历操作数组操作规约操作收集操作collect()需要传入一个结果容器,元素累加器,组合器1collect()是重载方法,可以传入Collectors的实例。1数学操作匹配操作查询操作装箱操作实践出真知构造IntStreamgenerate()会产生一个无限的流,这里需要使用limit限制。range()产生一个区间内的有序流。rangeClosed()产生一个区间内的有序流,包含区间最后一个元素。of()快速使用值创建流。empty()创建一个空流。builder()构造流。iterate()创建一个无限流。concat()合并两个流。操作IntStream过滤操作filter()过滤不满足条件的元素。转换操作map()mapToObj()mapToLong()mapToDouble()asLongStream()asDoubleStream()快速转成Double类型的Stream。拍扁操作flatMap()去重操作distinct()排序操作sorted()查看元素peek()限流操作limit()跳过操作skip()遍历操作forEach()forEachOrdered()数组操作toArray()规约操作reduce()收集操作collect()自定义逻辑。collect()传入Collectors。数学操作sum()max()min()count()average()summaryStatistics()匹配操作anyMatch()allMatch()noneMatch()查询操作findFirst()findAny()装箱操作boxed()将元素装箱。

    LoveIT 2020-09-08
    Java基础
  • Integer与int的比较

    Integer与int的比较

    如果面试官问Integer与int的区别:估计大多数人只会说到两点:Ingeter是int的包装类,注意是一个类;int的初值为0,Ingeter的初值为null。但是如果面试官再问一下Integeri=1;intii=1;i==ii为true还是为false?估计就有一部分人答不出来了,如果再问一下其他的,估计更多的人会头脑一片混乱。所以我对它们进行了总结,希望对大家有帮助。首先看代码:首先,8行和9行输出结果都为true,因为Integer和int比都会自动拆箱(jdk1.5以上)。13行的结果为true,而17行则为false,很多人都不懂为什么。其实java在编译Integeri5=127的时候,被翻译成Integeri5=Integer.valueOf(127);所以关键就是看**valueOf()**函数了。只要看看valueOf()函数的源码就会明白了。JDK源码的valueOf函数是这样的:当数字大于IntegerCache.low并且小于IntegerCache.high的时候就在IntegerCache内部的数组中缓存这个数,否则使用newInteger()在堆内存分配一个对象;IntegerCache看一下源码大家都会明白,对于-128到127之间的数,会进行缓存,Integeri5=127时,会将127进行缓存,下次再写Integeri6=127时,就会直接从缓存中取,就不会new了。所以13行的结果为true,而16行为false。对于18行和21行,因为对象不一样,使用==比较的地址,所以为false。实际运行情况如下:我对于以上的情况总结如下:①无论如何,Integer与newInteger()不会相等。不会经历拆箱过程,i7的引用指向堆,而newInteger()指向专门存放他的内存(常量池),他们的内存地址不一样,所以为false(如L24)。②两个都是非new出来的Integer,如果数在-128到127之间,则是true(如L18),否则为false(如L18)。java在编译Integeri2=128的时候,被翻译成->Integeri2=Integer.valueOf(128);而valueOf()函数会对-128到127之间的数进行缓存。③两个都是new出来的,都为false(如L27)。④int和integer(无论new否)比,都为true,因为会把Integer自动拆箱为int再去比(如L13、L14)。

    LoveIT 2020-08-28
    Java基础
  • Java并发编程实践

    Java并发编程实践

    一、使用两个线程交替打印输出“1A2B3C.....26Z”具体描述:使用两个线程,一个输出字母,一个输出数字,交替输出1A2B3C......26Z1、LockSupport使用JUC下的LockSupport工具类我们可以精确的阻塞或唤醒一个线程,并且LockSupport不需要配合ObjectMonitor使用,非常方便。这里我们主需要开启两个线程,然后各自在打印完之后唤醒对方并阻塞自己,交替进行即可。这里给出参考实现。2、CAS还可以使用自旋锁的思想,设置一个标记,如果没达到预期就不修改,话不多说,直接看代码:3、synchronized+wait+notify使用wait/notify可能是本题人家面试官的考点,人家就是想看你会不会使用wait/notify这种等待通知机制实现线程之间的通信,因此这种方式必须掌握。下面给出参考实现。4、ReentrantLock+Condition在JUC包有一个Condition可以实现和wait/notify一样的功能,并且不需要配合ObjectMonitor使用,但是需要同步,这里直接使用ReentrantLock就好了。下面给出参考实现二、两个线程,一个线程打印奇数,一个线程打印偶数,控制先一个奇数后一个偶数这种顺序1、synchronized+wait+notify基本思路和上面打印1A2B3C的思路一样,两个线程使用wait/notify控制唤醒和阻塞。然后使用CountDownLatch严格控制打印奇数的线程先运行。三、重显死锁四、生产者消费者模式在线程的世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发中,如果生产者处理速度很快,消费者处理速度很慢,那么生产者就必须等待消费者处理完才能继续生产;同样的道理,如果消费者的处理能力大图生产者,那么消费者就必须等待生产者。因此为了解决这种生产消费能力不平衡的问题,就有了生产者和消费者模式。生产者和消费者通过一个容器来解决生产者和消费者和强耦合问题。生产者和消费者彼此之间不直接统统,而是通过阻塞队列进行通信,生产者产生数据后直接扔进崔阻塞队列,消费者就到阻塞队列中获取数据消费就好了,这样一来,阻塞队列就相当于一个缓冲区,平衡了生产消费能力的不平衡。首先我们实现生产者和消费者,接着我会给出几种不同的Stock容器实现方式。生产者Productor:生产者持有商品容器,并实现了Runnable接口,在run方法中无限循环地往商品容器stock中放入商品。消费者Customer:消费者持有商品容器,并实现了Runnable接口,无限循环地从商品容器stock中取出商品消费。商品容器Stock接口:1、BlockingQueue实现直接使用阻塞队列实现,生产者只管生产,生产好了直接丢到阻塞队列中,消费者从阻塞队列中获取数据,获取到了就消费,没有数据了就阻塞住等待生产者生产。下面是简单实现。商品的管理队列StockBlockQueue:使用ArrayBlockingQueue同步商品信息技术要点:ArrayBlockingQueue主要有如下方法:add、offer、put都是放入元素。remove、poll、take都是移除元素。element、peek是获取头元素,但不移除。它们的实现不同:抛出异常:add()remove()element()返回一个特殊值(null或false,具体取决于操作):offer(e)poll()peek()操作成功前,无限期地阻塞:put(e)take()阻塞给定的时间:offer(e,time,unit)poll(time,unit)因此在使用的是否一定要注意使用他的阻塞方法,使用其他方法没有效果。2、synchronized+wait/notify实现该实现主要由synchronized、wait、notify配合使用。synchronized的语义大家应该都知道,当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。即同一时间内要么只有消费者执行take()方法,要么只有生产者执行put()方法。只有synchronized保证只有一个线程执行方法还不够,我们需要在容器空的时候,需要调用wait()让出锁进行等待,将执行权交给生产者生产商品,生产者生产完商品后再调用notify()方法通知消费者线程消费商品(有可能唤醒的还是生产者,如果唤醒的是还是生产者就继续生产商品直到容器满,让出锁进行等待。)。反之亦然。技术要点:在放入商品或取出商品时进行while条件判断,条件满足的话,进行等待。取出商品或者放入商品时通知其他线程。3、RenentrantLock+Condition实现该实现主要由ReentrantLock、以及customer、producer两个Condition来一起实现。Condition一样也是用来阻塞等待线程。那为什么需要两个Condition呢?可以看看刚才的例子,使用notifyAll()的时候可能会唤醒生产者和消费者。而两个Condition的话,我们可以在精准的控制唤醒,在消费者中唤醒生产者,在生产者中唤醒消费者。技术要点:1、将lock.lock()放到try块中。许多人会将lock放在trycatch块外面,这样很容易出现死锁。因为lock锁和synchronized锁不一样。synchronized锁会自动释放锁。而lock不会自动释放锁,必须手工释放锁。如果lock放在trycatch块之外的话,持有锁后却发生了异常,此时并不会释放锁。其他线程就永远得不到这个锁了。2、使用两个Condition实现精准的控制唤醒生产者和消费者。参考资料【1】生产者消费者模式的四种实现【2】Java多线程中的死锁问题【3】JAVA架构师分水岭:一道腾讯T3级别的多线程面试题区分平庸与卓越

    LoveIT 2020-08-06
    Java多线程与高并发
  • Hashtable源码分析

    Hashtable源码分析

    Hashtable是一个比较古老的Map实现类,从它的名称就可以看得出来,因为没有遵循Java语言的驼峰命名规范,这可能是开发者的疏忽导致的吧。它和HashMap很像,同属于散列表,都可以存储K-V键值对,并且都可以实现O(1)的查找时间复杂度。有以下特性:1、首先就是线程安全,这也估计算是唯一一个优于HashMap的特性了吧;2、Hashtable不允许key或者value为null;3、自从JDK1.2开始,Hashtable实现了Map接口,成为了Map容器中的一员。看样子最开始是不属于Map容器的。4、不建议使用,以后说不定哪天就废掉了。连官方文档也说了,如果在非线程安全的情况下使用,建议使用HashMap替换,如果在线程安全的情况下使用,建议使用ConcurrentHashMap替换。虽然不建议使用,但是面试的时候当问到HashMap时经常性的会和Hashtable比较,因此我们还是有必要大概研究一下Hashtable源码的,毕竟也不是很难,当读过HashMap源码再来读Hashtable源码就会发现他就是个“didi”!一、重要的属性方法Hashtable是继承自古老的Dictionary类,而Dictionary类,顾名思义,就是字典类,算是早期的Map,不过该类基本上已经废弃了。为什么废弃呢,大致看下Dictionary的源码就知道了。除了常规的get,put请求外,还提供了一些遍历的方法,返回的是Enumeration类型。而Enumeration接口其实算是被Iterator替换了,因为Iterator提供的功能更多,更方便。所以,就目前而言,Dictionary存在的意义恐怕就只是为了兼容原来继承它的一些类了吧。二、构造方法三、关键源码分析0、数组结点类型—Entry内部类Hashtable的结点类型Entry的结构和HashMap的Node的结构类似,每一个节点都会保存自身的hash、key、value、以及下个节点。如下图所示:1、put方法put流程很简单,这里大致梳理一下:1、首先检查key不能为null,如果是null就会抛出NullPointerException异常;2、如果key!=null,计算出key在哈希表中的桶位index,,然后遍历这个桶位的链表检查是否是值替换,如果是就替换并返回新值;3、如果不是值替换,那就添加新结点,此时会代用addEntry方法,下面是addEntry源码3.1、通过tab[index]=newEntry<>(hash,key,value,e);这一行代码,并且根据Entry的构造方法,我们可以知道,Hashtable是在链表的头部添加元素的,而HashMap是尾部添加的,这点可以注意下。3.2、Hashtable计算数组index的方式和HashMap有点不同,intindex=(hash&0x7FFFFFFF)%tab.length;0x7FFFFFFF也就是Integer.MAX_VALUE,也就是2的32次方-1,二进制的话也就是11111111...,那么(hash&0x7FFFFFFF)的含义看来看去好像只有对符号位有效了,就是负数的时候,应该是为了过滤负数,而后面的取模就很简单了,把index的取值限制在数组的长度之内。2、rehash()方法rehash流程梳理:1、首先计算出新容量newCapcity和新的阈值,并且实例化一个newCapcity大小的新Entry数组。newCapcity=(oldCapacity<<1)+1。容量的最大值是Integer.MAX_VALUE-82、之后从旧哈希表的尾部开始遍历每个哈希槽位,如果槽位中没有元素直接跳过,有元素则重新hash之后移动到新哈希表中。(元素在新哈希表的index=(e.hash&0x7FFFFFFF)%newCapacity。重新hash的原因是扩容之后哈希表的长度发生变化了,对哈希表取模的值会发生变化)3、get()方法get流程梳理:1、通过(key.hashCode()&0x7FFFFFFF)%tab.length获取当前key在table的索引。2、然后遍历当前链表找到对应的节点,如果没找到返回null。总结最后一幅脑图总结Hashtable的重要知识点:四、HashMap和Hashtable对比(1)HashMap的父类是AbstractMap,而Hashtable的父类是Dictionary。AbstractMap实现了Map接口,它以最大限度地减少实现此接口所需的工作;而Dictionary是任何可将键映射到相应值的类的抽象父类;(2)HashMap的默认初始容量是16,最大容量是2^30,并且HashMap的哈希数组是懒加载的,即调用构造方方法之后不会立即实例化而是在第一次put的时候初始化哈希数组;Hashtable的默认初始容量是11,最大容量是Integer.MAX_VALUE-8,并且Hashtable的哈希数组在调用构造方法后立即实例化的;(3)HashMap的扩容是旧数组大小的2倍,Hashtable的扩容是就数组的大小的2倍+1;(4)HashMap的key和value都允许为null,而Hashtable的key和value都不允许为null。HashMap遇到key为null的元素会直接把它放到哈希数组的0号槽位,但是Hashtable遇到key或value为null的元素会抛出NullPointerException异常;(5)HashMap不是线程安全的,但是Hashtable是线程安全的;即使是在jdk1.8的HashMap中即使解决了多线程下扩容导致的死循环问题,但是在多线程环境下还是会存在数据的共享读写问题;Hashtable几乎所有的public方法都使用synchronize修饰,可以保证数据的安全性问题,但是效率不高,因此想使用线程安全的Map还是推荐使用ConcurrentHashMap;参考资料【1】Java1.8-Hashtable源码解析

    LoveIT 2020-08-02
    Java集合与容器
  • 关于HashMap几个刁钻的面试题,第四个我就跪了

    关于HashMap几个刁钻的面试题,第四个我就跪了

    1、HashMap的数据结构是什么?答:在jdk1.7是采用了数组+链表;jdk1.8采用了数组+链表+红黑树,当链表长度大于等于的时候转化为红黑树,当红黑树的结点小于等于6的是时候就有红黑树转化为链表;2、为什么要采用数组+链表作为存储结构?首先要清楚一个基本的理论:数组查询效率高,只要给一个数组索引就可以立马找到对应的元素,但是插入、删除的效率低;链表插入、删除的效率高,但是查询的效率低。因此HashMap的Node数组就可以通过一个索引值(index=(n-1)&hash)立即定位到确定到元素的槽位(solt)。但是可能有多个key的hashCode计算出来一样即发生Hash碰撞,此时HashMap采用了拉链法,即以table数组这个槽位为链表头结点,把发生hash碰撞元素串在一根链表上。而且在jdk1.8进行了进一步优化,即如果链表的长度>=8时将会转化为红黑树,当红黑树上的结点<=6时又会转换为链表。这一点从源码中就可以看到:TREEIFY_THRESHOLD是树化阈值为8,UNTREEIFY_THRESHOLD是非树化阈值为63、你知道hash的实现吗?为什么要这样实现(为什么要用异或运算符)?jdk1.8的hash值是通过对key的hashCode的高16位和低16位进行异或得到的:hash=(h=key.hashCode())^(h>>>16);这样实现可以让高位的数字也可以参与到hash值的计算,从而使得哪怕一点变化都会引起hash函数有很大的不同,最终目的还是为了尽可能的实现hash函数的均匀分布。4、说一下HashMap的put和get的工作原理?HashMap底层是hash数组和单向链表实现,数组中的每个元素都是链表,由Node内部类(实现Map.Entry<K,V>接口)实现,HashMap通过put和get方法存储和获取。存储对象时,将K/V键值传给put()方法:(1)调用hash(K)方法计算K的hash值,然后结合数组长度,计算得数组下标;(2)调整数组大小(当容器中的元素个数大于capacity*loadfactor时,容器会进行扩容resize为2n);(3)i.如果K的hash值在HashMap中不存在,则执行插入,若存在,则发生碰撞;ii.如果K的hash值在HashMap中存在,且它们两者equals返回true,则更新键值对;iii.如果K的hash值在HashMap中存在,且它们两者equals返回false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。(JDK1.7之前使用头插法、JDK1.8使用尾插法)(注意:当碰撞导致链表大于TREEIFY_THRESHOLD=8并且哈希表的容量大于64时,就把链表转换成红黑树,否则即使链表的长度大于8了,也不会立即将链表转化为红黑树,而是使用扩容来解决)获取对象时:(1)get()方法将键值对传入,首先还是计算key的hash值(2)拿到hash值首先判断一下头结点是不是要找的元素,如果是直接返回value;如果不是就看看头结点是否有后继结点,如果没有就返回null(3)如果头结点有后续结点那就判断如果头结点的类型如果是树就调用红黑树的获取值的方法getTreeNode(),否则还是当做链表处理,从头结点开始遍历,知道hash值相等并且equals返回true是就说明找到目标元素了。hashCode是定位的,存储位置;equals是定性的,比较两者是否相等。5、HashMap扩容的原理?HashMap的扩容过程总的来说分为两个过程:根据老哈希表的大小计算出新哈希表的新容量以及新阈值,并创建一个新的Node空数组,长度是原数组的2倍遍历原Entry数组,把所有的Entry重新Hash到新数组。核心思路:遍历老的哈希表,没有元素的槽位不管,只处理有元素的槽位;处理有元素的槽位的逻辑分为三大部分:1)如果这个槽位没有后继结点了(没有形成链表),就重新计算元素移动到新的哈希表中位置并直接移动过去,重新计算桶位的算法还是:e.hash&(newCap-1)2)如果槽位有后继结点并且已近树化了(已经是红黑树了),那就将红黑树拆分后,在新的哈希表中重新hash并散列到新的Node数组中3)如果槽位有后继结点但是没有树化(已经是链表了),那就还是用尾插法把元素移动到新的哈希表中,需要注意的是:A:扩容后,若e.hash&oldCap=0,那么元素在扩容后的位置=原始位置(newTab[j]=loHead)B:扩容后,若hash&oldCap!=0,那么元素在扩容后的位置=原始位置+旧数组大小(newTab[j+oldCap]=hiHead)。6、HashMap的table的容量如何确定?loadFactor是什么?该容量如何变化?这种变化会带来什么问题?(1)HashMap的table数组的容量是通过capaticy参数控制的,默认的数组大小是16,最大额数组大小是2^30。我们可以在HashMap的构造方法中传入指定的容量数值,尤其是数据量大的时候指定初始容量非常有必要。(2)loadFatory是装载因子,作用是判断当前哈希表是否可以扩容了。范围是0-1,默认是0.75.和他类似的一个参数是阈值threshold,在一切都是默认值的情况下,阈值是12(3)扩容时,调用resize()方法,将table长度变为原来的两倍(注意是table长度,而不是threshold)这一问之后有可能会再问,加载因子默认为什么设置为0.75?**选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择,**加载因子过高,例如为1,虽然减少了空间开销,提高了空间利用率,但同时也增加了查询时间成本;加载因子过低,例如0.5,虽然可以减少查询时间成本,但是空间利用率很低,同时提高了rehash操作的次数。7、说说你对红黑树的了解?红黑树是一种自平衡的二叉查找树,但是每个节点上增加一个存储位表示节点的颜色(红色或黑色),红黑树的查找、删除、插入操作最坏的时间复杂度都可以达到O(lgn)。总的来说,红黑树除了符合二叉查找树的基本特性外,还具有以下几个特性:红黑树的结点是红色的或黑色的;红黑树的根节点是黑色的;叶子节点(NULL结点)都是黑色的;每个红色节点的两个子节点都为黑色(红黑树不会出现相邻的红色节点);从任意节点出发,到其每个叶子节点的路径中包含相同数量的黑色节点;新加入到红黑树的节点为红色节点。8、为什么在jdk1.8要使用红黑树,使用二叉搜索树或者平衡树不可以吗?(1)二叉搜索树在给定的数据有序时会出现退化为链表的情况二叉搜索树的思想是:左子树结点比父节点小,右子树结点比父节点大。当给的数据处于无序状态的时候二叉搜索树的性能非常好,只有O(logn),但是当给的数据有序的时对于二叉搜索树是灾难,基于二叉搜索树规则构建的树会退化为单链表。即如下图所示:(2)平衡树的维护成本太高,不适合HashMap这样有频繁修改的的场景。平衡树(AVL)就是用来解决二叉搜索树会退化为链表对问题二提出的。它的原理如下:a.平衡树具有二叉搜索树的全部特性b.平衡树要求左子树和右子树的高度差不能超过1通过平衡树,我们解决了二叉查找树的缺点。对于有n个节点的平衡树,最坏的查找时间复杂度也为O(logn)。但是由于平衡树要求每个节点的左子树和右子树的高度差至多等于1,这个要求实在是太严了,导致每次进行插入/删除节点的时候,几乎都会破坏平衡树的第二个规则,进而我们都需要通过左旋和右旋来进行调整,使之再次成为一颗符合要求的平衡树。最后就会导致为了维护一个这个平衡树,带来了可能比只是用单链表还高的消耗,这时不可取得。因此为了解决这个问题,工程师们又想到了红黑树。红黑树的性质如下:a.具有二叉查找树数的所有特点b.根节点是黑色的c.每个叶子节点都是黑色的空节点,即叶子节点不存值d.任意相邻的结点都不能是相同的颜色e.任意结点到其任意叶子节点的路径上的黑色结点的数目相同正是由于红黑树的这些特性,红黑树在插入、删除等操作,不会像平衡树那样,频繁着破坏红黑树的规则,所以不需要频繁调整。但是在效率方面,红黑树确实不及平衡树,可以说使用红黑树就是一种折中的方案。9、为什么不直接上来就使用红黑树,而是达到阈值了才转化为红黑树?首先维护一个树的成本比维护一个链表的成本要高。再者设置链表树化的阈值为8是因为链表的长度达到8的概率很低,看HashMap源码中的一段注释:画红线的翻译过来就是说:在具有良好分布的用户hashCode的用法中,很少使用到红黑树。理想情况下,在随机hashCodes下,bin中节点的频率遵循泊松分布,默认调整大小阈值为0.75,平均参数约为0.5,尽管由于存在较大差异调整粒度。忽略差异,预期列表大小k的出现是**(exp(-0.5)*pow(0.5,k)/阶乘(k)**)。也就是说,想让一个链表的长度达到8的概率极低,几乎是不可能事件。因此综合这两点,为啥一定要维护一个红黑树呢?没必要啊!!!10、jdk1.7和jdk1.8中HashMap有哪些不同?(1)在jdk1.8中当链表的长度大于等于8的时候会转换诶红黑树;(2)jdk1.8中采用的是尾插法,而jdk1.7中采用的是头插法;(3)jdk1.8简化了hash算法;(4)jdk1.8修改了resize()的逻辑,使得jdk1.8在resize时不会出现循环链表的问题;(5)jdk1.8中talbe数组的类型改为了Node,jdk1.7中的类型是Entry类型;如果你说了jdk1.8中采用的是尾插法,而jdk1.7中采用的是头插法,人家就会问jdk1.8为啥使用尾插法?首先是为了解决在多先线程环境下由于头插法带来的形成环形链表的问题;在jdk1.8中引入了红黑树,为了统计链表总的结点个数,以便判断是否需要树化,于是他就遍历了一遍链表并使用一个binCount的变量计数,当遍历完之指针就是在最后一个节点上指着呢,采用尾插顺理成章。11、HashMap,LinkedHashMap,TreeMap有什么区别?LinkedHashMap是HashMap的子类,他维护了一个双向链表,因此他可以保证元素的插入顺序和元素的遍历顺序是相同的;效率比HashMap低。TreeMap就是一个红黑树的实现,实现SortMap接口,能够把它保存的记录根据键排序12、HashMap和Hashtable的区别?(1)HashMap的基类是AbstractMap,Hashtable的基类是Dictionary,他们共同实现了Map接口(2)HashMap的初始容量是16,Hashtable的初始容量都是11,负载因子都是0.75。但是扩容机制不同,HashMap是旧数组的2×旧表长度,而Hashtable是2×旧表长度+1(3)HashMap是非线程安全的,而Hashtable是线程安全的,因为所有的方法都使用了synchronized.(4)HashMap使用迭代器迭代,Hashtable可以使用迭代器和枚举。(6)HashMap中K和V都可以是null(存放在table[0]),但是Hashtable中都不能是null.(7)HashMap中取消了contains方法,使用了containsKey和containsValue,但是Hashtable中三个方法都有(9)对象的定位方法不同:Hashtable:使用K的hashCode直接作为hash值,和数组长度进行求余运算,得到键值对在数组中的位置,然后再使用equals方法形成链表。HashMap:使用K的hashCode进行高低16位异或运算作为hash值,和数组的长度减一进行&运算,得到键值对在数组中的位置,然后再使用equals方法形成链表。说一下计算桶的位置为什么是这个样子,就是因为扩容的时候是2的n次方进行扩容,hash值在和2的n次方进行求余运算和&运算的结果一样,但是&运算要快的多。同时正是因为扩容倍数的特殊性,导致扩容后不需要重新键值对在新数组的位置只需要判断K的hash值多出来的那一位是0还是1.如果是0,新表中键值对的位置和旧表-样。如果是1,新表中键值对的位置等于旧表的位置+旧表的长度。(9)Hashtable由于是线程安全的,因此采用了一种快速失败的机制。允许多个线程同时修改但不会抛出异常;HashMap采用了一种安全失败机制的机制,他不允许在遍历元素的时候,集合发生改,如果发生改变就会抛出异常。13、Java中的另一个线程安全的与HashMap极其类似的类是什么?同样是线程安全,它与Hashtable在线程同步上有什么不同?ConcurrentHashMap是J.U.C下提供的一个并发容器,他是HashMap的线程安全版本,采用分段锁提高了效率,但是在Hashtable中是将整个hash表使用synchronized锁起来,并发性没有前者好。JDK1.7中使用分段锁(ReentrantLock+Segment+HashEntry),相当于把一个HashMap分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于Segment,包含多个HashEntry。JDK1.8中使用CAS+synchronized+Node+红黑树。锁粒度:Node(首结点)(实现Map.Entry<K,V>)。锁粒度降低了

    LoveIT 2020-07-28
    Java集合与容器
  •  深入浅出 Java 8 Lambda 表达式和函数式接口

    深入浅出 Java 8 Lambda 表达式和函数式接口

    1、为什么Java需要Lambda表达式?Java是一门的面向对象语言,除了部分简单数据类型,Java中的一切都是对象,即使数组也是一种对象。在Java中定义的函数或方法不可能完全独立,也不能将方法作为参数或返回一个方法给实例。在Swing编程中,我们总是通过匿名类给方法传递函数功能,以下是旧版的事件监听代码:在上面的例子里,为了给Mouse监听器添加自定义代码,我们定义了一个匿名内部类MouseAdapter并创建了它的对象,通过这种方式,我们将一些函数功能传给addMouseListener方法。简而言之,在Java里将普通的方法或函数像参数一样传值并不简单,为此,Java8增加了一个语言级的新特性,名为Lambda表达式。因此上面的代码就可以简化:在SteveYegge辛辣又幽默的博客文章里,描绘了Java世界是如何严格地以名词为中心的,如果你还没看过,赶紧去读吧,写得非常风趣幽默,而且恰如其分地解释了为什么Java要引进Lambda表达式。Lambda表达式为Java添加了缺失的函数式编程特点,使我们能将函数当做一等公民看待。尽管不完全正确,我们很快就会见识到Lambda与闭包的不同之处,但是又无限地接近闭包。在支持一类函数的语言中,Lambda表达式的类型将是函数。但是,在Java中,Lambda表达式是对象,他们必须依附于一类特别的对象类型——函数式接口(functionalinterface)。2、Lambda表达式简介Lambda表达式是一种匿名函数,简单地说,它是没有声明的方法,也没有访问修饰符、返回值声明和名字。你可以将其想做一种速记,在你需要使用某个方法的地方写上它。当某个方法只使用一次,而且定义很简短,使用这种速记替代之尤其有效,这样,你就不必在类中费力写声明与方法了。(1)Lambda表达式的结构Lambda表达式是java8中提供的一种新的特性,它支持Java也能进行简单的“函数式编程”。它是一个匿名函数,Lambda表达式基于数学中的λ演算得名,直接对应于其中的lambda抽象(lambdaabstraction),是一个匿名函数,即没有函数名的函数。java8中lambda表达式有三部分组成:第一部分为一个括号内用逗号分隔的形式参数,参数是函数式接口里面方法的参数;第二部分为一个箭头符号:->;第三部分为方法体,可以是表达式和代码块。语法如下:以下是一些lambda表达式的例子:关于lambda表达式的语法需要特别注意以下特性:一个Lambda表达式可以有零个或多个参数参数的类型既可以明确声明,也可以根据上下文来推断。例如:(inta)与(a)效果相同所有参数需包含在圆括号内,参数之间用逗号相隔。例如:(a,b)或(inta,intb)或(Stringa,intb,floatc)空圆括号代表参数集为空。例如:()->42当只有一个参数,且其类型可推导时,圆括号()可省略。例如:a->returna*aLambda表达式的主体可包含零条或多条语句如果Lambda表达式的主体只有一条语句,花括号{}可省略。匿名函数的返回类型与该主体表达式一致如果Lambda表达式的主体包含一条以上语句,则表达式必须包含在花括号{}中(形成代码块)。匿名函数的返回类型与代码块的返回类型一致,若没有返回则为空3、Lambda表达式的函数式接口java8的lambda表达式实质是是以匿名内部类的形式的实现的。看下面代码。代码中我们定义了一个叫opt的Lambda表达式,看返回值它是一个IntBinaryOperator实例。那么你有想过为什么匿名内部类可以这个简写吗?我们来看看IntBinaryOperator的定义:从源码可以看到,IntBinaryOperator这个接口被一个叫**@FuncationalInterface的注解修饰了,并且在这个解口中只有一个抽象方法,在Java8以后我们把使用@FunctionalInterface标注了的并且只有一个抽象方法的接口称作这是一个函数式接口**。同样地,java.lang.Runnable也是一个函数式接口,在Runnable接口中只声明了一个方法voidrun(),我们使用匿名内部类来实例化函数式接口的对象,有了Lambda表达式,这一方式可以得到简化。下图所示是Runnable接口的定义。了解到这里,我觉得我们应该对函数式接口有个更深层次的认识。函数式接口(FunctionalInterface)是JAVA8对一类特殊类型的接口的称呼。这类接口只定义了唯一的抽象方法的接口(除了隐含的Object对象的公共方法,因此最开始也就做SAM类型的接口(SingleAbstractMethod)。定义函数式接口的原因是在JavaLambda的实现中,开发组不想再为Lambda表达式单独定义一种特殊的Structural函数类型,称之为箭头类型(arrowtype,依然想采用Java既有的类型(class,interface,method等).原因是增加一个结构化的函数类型会增加函数类型的复杂性,破坏既有的Java类型,并对成千上万的Java类库造成严重的影响。权衡利弊,因此最终还是利用SAM接口作为Lambda表达式的目标类型.另外对于函数式接口来说@FunctionalInterface并不是必须的,只要接口中只定义了唯一的抽象方法的接口那它就是一个实质上的函数式接口,就可以用来实现Lambda表达式。常用的函数式接口在java8中已经为我们定义了很多常用的函数式接口它们都放在java.util.function包下面,一般有以下常用的四大核心接口:函数式接口参数类型返回类型用途Consumer(消费型接口)Tvoid对类型为T的对象应用操作。voidaccept(Tt)Supplier(供给型接口)无T返回类型为T的对象。Tget();Function(函数型接口)TR对类型为T的对象应用操作并返回R类型的对象。Rapply(Tt);Predicate(断言型接口)Tboolean确定类型为T的对象是否满足约束。booleantest(Tt);接下来我们来详细了解一下每个接口的作用。Consumer消费型接口**消费型接口就是有输入没有返回的接口,有进无出所以叫消费者Consumer**,如果要定义一个有参的无返回值的抽象方法的接口时,可以直接使用Consumer。可以看到在Consumer接口中有两个方法,但是只有一个accept抽象方法并且使用@FunctionalInterface修饰了,因此他符合函数式接口的定义。其中accept方法用于指定一个消费者的消费行为的,需要传入一个参数,参数的类型由泛型决定,默认方法andThen作用是合并2个消费者生成一个新的消费者,先执行第一个消费者的accept方法,再执行第二个消费者的accept方法 Supplier生产者接口生产者接口无需传递参数但是有返回值,他返回一个泛型参数指定类型的对象实例,无进有出所以叫生产者或提供者Supplier。如果要定义一个无参的有Object返回值的抽象方法的接口时,可以直接使用Supplier,不用自己定义接口了。废话不多说,直接上DemoFunction函数型接口Function接口用来根据一个类型的数据得到另一个类型的数据,前者称为前置条件,后者称为后置条件。有进有出,所以称为“函数Function”。该接口可以理解成一个数据工厂,用来进行数据转换,将一种数据类型的数据转换成另一种数据.泛型参数T:要被转换的数据类型(原料),泛型参数R:想要装换成的数据类型(产品)。Predicate断言接口Predicate接口主要是对某种类型的数据进行判断,返回一个boolean型结果。可以理解成用来对数据进行筛选。当需要定义一个有参并且返回值是boolean型的方法时,可以直接使用Predicate接口中的抽象方法4、Lambda表达式中的Stream什么是Stream?官方解释:简单来讲,stream就是JAVA8提供给我们的对于元素集合统一、快速、并行操作的一种方式。它能充分运用多核的优势,以及配合lambda表达式、链式结构对集合等进行许多有用的操作。Stream就如同一个迭代器(Iterator),单向,不可往复,数据只能遍历一次,遍历过一次后即用尽了,就好比流水从面前流过,一去不复返。而和迭代器又不同的是,Stream可以并行化操作,迭代器只能命令式地、串行化操作。顾名思义,当使用串行方式去遍历时,每个item读完后再读下一个item。而使用并行去遍历时,数据会被分成多个段,其中每一个都在不同的线程中处理,然后将结果一起输出。Stream的并行操作依赖于Java7中引入的Fork/Join框架(JSR166y)来拆分任务和加速处理过程。Stream也有很多种创建方式,常见的创建方式有:(1)由值创建流:(2)由数组创建流:这里也可以使用集合创建流(3)由文件创建流(4)上面的这些Stream都是有限的,我们可以用函数来创建一个无限StreamStream也很懒惰,它只会在你真正需要数据的时候才会把数据给传给你,在你不需要时它一个数据都不会产生。5、Lambda表达式使用指南(1)保持Lambda表达式简短和一目了然长长的Lambda表达式通常是危险的,因为代码越长越难以读懂,意图看起来也不明,并且代码也难以复用,测试难度也大。(2)使用@FunctionalInterface注解如果你确定了某个interface是用于Lambda表达式,请一定要加上*@FunctionalInterface,表明你的意图。*不然将来说不定某个不知情的家伙比如你旁边的好基友,在这个interface上面加了另外一个抽像方法时,你的代码就悲剧了。(3)优先使用java.util.function包下面的函数式接口java.util.function这个包下面提供了大量的功能性接口,可以满足大多数开发人员为lambda表达式和方法引用提供目标类型的需求。每个接口都是通用的和抽象的,使它们易于适应几乎任何lambda表达式。开发人员应该在创建新的功能接口之前研究这个包,避免重复定义接口。另外一点就是,里面的接口不会被别人修改~。(4)不要在Lambda表达中执行有"副作用"的操作**"**副作用"是严重违背函数式编程的设计原则,在工作中我经常看到有人在forEach操作里面操作外面的某个List或者设置某个Map这其实是不对的。(5)不要把Lambda表达式和匿名内部类同等对待虽然我们可以用匿名内部类来实现Lambda表达式,也可以用Lambda表达式来替换内部类,但并不代表这两者是等价的。这两者在某一个重要概念是不同的:this指代的上下文是不一样的。当您使用内部类时,它将创建一个新的范围。通过实例化具有相同名称的新局部变量,可以从封闭范围覆盖局部变量。您还可以在内部类中使用这个关键字作为它实例的引用。但是,lambda表达式可以使用封闭范围。您不能在lambda的主体内覆盖范围内的变量(6)多使用方法引用在Lambda表达式中a->a.toLowerCase()和String::toLowerCase都能起到相同的作用,但两者相比,后者通常可读性更高并且代码会简短。(7)尽量避免在Lambda的方法体中使用{}代码块优先使用而不是(8)不要盲目的开启并行流Lambda的并行流虽好,但也要注意使用场景。如果平常的业务处理比如过滤,提取数据,没有涉及特别大的数据和耗时操作,则真的不需要开启并行流。因为多行线程的开启和同步这些花费的时间往往比你真实的处理时间要多很多。但一些耗时的操作比如I/O访问,DB查询,远程调用,这些如果可以并行的话,则开启并行流是可提升很大性能的。因为并行流的底层原理是fork/join,如果你的数据分块不是很好切分,也不建议开启并行流。举个例子ArrayList的Stream可以开启并行流,而LinkedList则不建议,因为LinkedList每次做数据切分要遍历整个链表,这本身就已经很浪费性能,而ArrayList则不会。参考资料[1]https://www.cnblogs.com/linlinismine/p/9283532.html[2]http://blog.oneapm.com/apm-tech/226.html[3]https://www.cnblogs.com/JohnsonLiu/p/9863309.html

    LoveIT 2020-07-11
    Java基础
  • 一文让你理解as-if-serila和happens-before语义

    一文让你理解as-if-serila和happens-before语义

    概述本文参考《Java并发编程的艺术》一书,温故而知新,加深对基础的理解。一、指令序列的重排序我们在编写代码的时候,通常自上而下编写,那么希望执行的顺序,理论上也是逐步串行执行,但是为了提高性能,编译器和处理器常常会对指令做重排序。从Java源码到最终实际的指令,需要经过三个阶段的重排序:1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。JMM属于语言级别的内存模型,他确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。二、as-if-serial语义as-if-serial语义的含义是:无论如何重排序,单线程程序的执行结果不能被改变。编译器和处理器都必须遵循as-if-serial语义,编译器和处理器为了遵循这一易于,他们就不会对存在数据依赖关系的操作进行重排序。具体来说就是如下表的操作就不会重排序:as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。三、happens-before规则happens-before是JMM的核心概念,通过happens-before规则JMM可以实现:一方面,为程序员提供了足够强的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能的宽松;1、happens-before规则JSR-133规范中定义了如下happens-before规则:(1)程序顺序规则:一个线程中的每个操作,happens-brfore于该线程中的任意后序操作。(2)监视器锁规则:线程对解锁obj之前的对变量的写,对接下来对obj加锁的线程对变量的读可见。(3)volatile变量规则:一个线程对volatile的写,对接下其他变量对该变量的读可见。(4)传递性:如果Ahappens-beforeB,且Bhappens-beforeC,那么Ahappens-beforeC。(5)start()规则:线程start()之前对变量的写,对该变量开始后对该变量可见。(6)join()规则:线程结束前对变量的写,对其他线程得知他结束后的读可见。(比如线程调用isAlive()、join())总的来说:happens-before和as-if-serial语义是一回事as-if-serial语义保证单线程内程序的执行结果不被重排序改变,hapeens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。as-if-serial语义给编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。happens-before规则给编写正确同步的多线程程寻的程序员一个幻觉:正确同步的多线程程序是按happens-before指定的顺序来执行的。

    LoveIT 2020-06-07
    Java多线程与高并发
  • 秒懂,Java 注解 (Annotation)看这一篇就够了

    秒懂,Java 注解 (Annotation)看这一篇就够了

    Annotation中文译过来就是注解、标释的意思,在Java中注解是一个很重要的知识点,但经常还是有点让新手不容易理解。我们经常可以看到一些文章对注解的解释如下:Java注解用于为Java代码提供元数据。作为元数据,注解不直接影响你的代码执行,但也有一些类型的注解实际上可以用于这一目的。Java注解是从Java5开始添加到Java的。这段对于注解(Annotation)的解释确实正确,但是说实在话,我第一次学习的时候,头脑一片空白。这什么跟什么啊?听了像没有听一样。因为概念太过于抽象,所以初学者实在是比较吃力才能够理解,然后随着自己开发过程中不断地强化练习,才会慢慢对它形成正确的认识。所以,我理解的注解就是一种标签,想像代码具有生命,注解就是对于代码中某些鲜活个体的贴上去的一张标签,那么注解就是给编译器看的一种标签,通过这个标签,编译器可以快速“联想”到具体的代码。一、注解的语法熟悉JavaWeb开发的同学对注解应该不会陌生,Spring家族使用了大量的注解来简化了我们的开发,其实同和class和interface一样,注解也属于一种类型。它是在JavaSE5.0版本中开始引入的概念。1、注解的定义注解通过@interface注解来定义它的形式跟接口很类似,不过前面多了一个@符号。上面的代码就创建了一个名字为MyAnnotation的注解。你可以简单理解为创建了一张名字为MyAnnotation的标签。定义好注解之后,我们就可以使用注解了,最简单的就是在一个类头上使用注解:你可以简单理解为将MyAnnotation这张标签贴到Test这个类上面。不过,要想注解能够正常工作,还需要介绍一下一个新的概念那就是元注解。2、元注解元注解是什么意思呢?元注解是可以加解到注解上的注解,或者说元注解是一种基本注解,但是它能够应用到其它的注解上面。如果难于理解的话,你可以这样理解。元注解也是一张标签,但是它是一张特殊的标签,它的作用和目的就是给其他普通的标签进行解释说明的。Java提供的元标签有@Retention、@Documented、@Target、@Inherited、@Repeatable5种。下面详细来讲解一下。(1)@RententionRetention的英文意为保留期的意思。当@Retention应用到一个注解上的时候,它解释说明了这个注解的的存活时间。它的取值如下:RetentionPolicy.SOURCE注解只在源码阶段保留,在编译器进行编译时它将被丢弃忽RetentionPolicy.CLASS注解只被保留到编译进行的时候,它并不会被加载到JVM中RetentionPolicy.RUNTIME注解可以保留到程序运行的时候,它会被加载进入到JVM中,所以在程序运行时可以获取到它们。当使用@Rentention标注在一个注解上的时候就表示指定这个注解的工作时间。上面的代码中,我们指定MyAnnotation可以在程序运行周期被获取到,因此它的生命周期非常的长。(2)@Document这是一个和文档有关的注解,它的作用是能够将注解中的元素包含到Javadoc中去。(3)@TargetTarget是目标的意思,@Target指定了注解运用的地方。当一个注解被@Target注解时,这个注解就被限定了运用的场景。@Target有下面的取值:ElementType.ANNOTATION_TYPE:可以在注解上使用ElementType.CONSTRUCTOR:可以在构造器上使用ElementType.FIELD:可以在属性上使用ElementType.LOCAL_VARIABLE:可以在局部变量上使用ElementType.METHOD:可以在方法上使用ElementType.PACKAGE:可以在包名上使用ElementType.PARAMETER:可以在一个方法的形参上使用ElementType.TYPE:可以在类、接口、枚举类等头上使用(4)@InheritedInherited是继承的意思,但是它并不是说注解本身可以继承,而是说如果一个超类被@Inherited注解过的注解进行注解的话,那么如果它的子类没有被任何注解应用的话,那么这个子类就继承了超类的注解。注解MyAnnotation被@Inherited修饰,之后类SuperClass被MyAnnotation注解,类SubClass继承SuperClass,虽然没有在类SubClass上标注,但是类SubClass也拥有MyAnnotation这个注解。这是@Inherited注解的含义。(5)@RepeatableRepeatable自然是可重复的意思。@Repeatable是Java1.8才加进来的,所以算是一个新的特性。他的含义是说明这个注解可以在同一个Element上多次标记。在Person注解上使用@Repeatable之后,@Person就可以在一个类上多次使用。好了,关于Java中的5个元注解就说到这,接下来,我们在来了解一下注解的属性如果定义。3、注解的属性注解的属性也叫做成员变量。注解只有成员变量,没有方法。注解的成员变量在注解的定义中以“无形参的方法”形式来声明,其方法名定义了该成员变量的名字,其返回值定义了该成员变量的类型。比如:上面就定义了给MyAnnotation这个注解定义了两个属性:value和key。在定义的时候我们还可以使用default关键字给属性指定默认值:上面给两个属性有指定了默认值,在使用的时候我们也可以给属性指定一个值,多个属性用逗号(,)隔开:另外在使用注解的时候,如果注解中只有一个属性,那么可以不写属性名直接写值也可以,如果注解中没有任何属性,那么直接使用注解标注就可以了。二、Java中预置的重要注解学习了上面相关的知识,我们已经可以自己定义一个注解了。其实Java语言本身已经提供了几个现成的注解。(1)@Deprecated这个是用来标记过期元素的,他几乎何以作用在任何元素上。如果在我们的代码中使用额被@Deprecated标记的API,那么在编译阶段编译器就会发出警告,告诉开发者正在调用一个过时的元素比如过时的方法、过时的类、过时的成员变量。(2)@Override这个大家应该很熟悉了,提示子类要复写父类中被@Override修饰的方法(3)@SuppressWarnings阻止警告的意思。之前说过调用被@Deprecated注解的方法后,编译器会警告提醒,而有时候开发者会忽略这种警告,他们可以在调用的地方通过@SuppressWarnings达到目的。(4)@SafeVarargs参数安全类型注解。它的目的是提醒开发者不要用参数做一些不安全的操作,它的存在会阻止编译器产生unchecked这样的警告。它是在Java1.7的版本中加入的。(5)@FunctionalInterface函数式接口注解,这个是Java1.8版本引入的新特性。函数式编程很火,所以Java8也及时添加了这个特性。函数式接口(FunctionalInterface)就是一个只有一个抽象方法的接口(注意是只有一个抽象方法,不是只有一个方法)。比如:我们进行线程开发中常用的Runnable就是一个典型的函数式接口,上面源码可以看到它就被@FunctionalInterface注解。三、注解的提取前面我通过用标签来比作注解,前面的内容是讲怎么写注解,然后贴到哪个地方去,而现在我们要做的工作就是检阅这些标签内容。形象的比喻就是你把这些注解标签在合适的时候取下来,然后检阅上面的内容信息。要想正确检阅注解,离不开一个手段,那就是反射。注解与反射注解通过反射获取。可以通过Class类的isAnnotationPresent()方法判断是否应用了某个注解,使用isAnnotation()判断是否是注解然后可以使用Class类的getAnnotation()方法或getAnnotations()方法来获取Annotation对象。前一个方法返回指定类型的注解,后一个方法返回注解到这个元素上的所有注解。如果获取到的Annotation如果不为null,则就可以调用它们的属性方法了。比如执行结果:四、注解的使用场景我相信讲到这里大家都很熟悉了注解,但是有不少同学肯定会问,注解到底有什么用呢?对啊注解到底有什么用?我们不妨将目光放到Java官方文档上来。文章开始的时候,我用标签来类比注解。但标签比喻只是我的手段,而不是目的。为的是让大家在初次学习注解时能够不被那些抽象的新概念搞懵。既然现在,我们已经对注解有所了解,我们不妨再仔细阅读官方最严谨的文档。注解是一系列元数据,它提供数据用来解释程序代码,但是注解并非是所解释的代码本身的一部分。注解对于代码的运行效果没有直接影响。注解有许多用处,主要如下:提供信息给编译器:编译器可以利用注解来探测错误和警告信息编译阶段时的处理:软件工具可以用来利用注解信息来生成代码、Html文档或者做其它相应处理。运行时的处理:某些注解可以在程序运行的时候接受代码的提取值得注意的是,注解不是代码本身的一部分。注解主要针对的是编译器和其它工具软件(SoftWaretool)。当开发者使用了Annotation修饰了类、方法、Field等成员之后,这些Annotation不会自己生效,必须由开发者提供相应的代码来提取并处理Annotation信息。这些处理提取和处理Annotation的代码统称为APT(AnnotationProcessingTool)。所以现在,我们可以给自己答案了,注解有什么用?给谁用?给编译器或者APT用的。因此,注解能干什么,取决于你想用它来干什么!总结如果注解难于理解,你就把它类同于标签,标签为了解释事物,注解为了解释代码。注解的基本语法,创建如同接口,但是多了个@符号。注解的元注解。注解的属性。注解主要给编译器及工具类型的软件用的。注解的提取需要借助于Java的反射技术,反射比较慢,所以注解使用时也需要谨慎计较时间成本。参考:https://blog.csdn.net/briblue/article/details/73824058

    LoveIT 2020-06-07
    Java基础
  • JAVA集合总结,将知识点一网打尽!

    JAVA集合总结,将知识点一网打尽!

    Java集合框架体系总览总的来说,Java集合框架以Collection接口为中心,下属三个重要的子接口,分别是:List(线性表)、Set、Queue(队列),以及还有一个非常重要的Map接口。下面我们分别总结一下每种类型集合下的知识点。一、Collection接口首先我们来看一下Collection接口中的方法:二、Iterable接口Collection接口继承了Iterable接口,说明所有的集合元素都是可以通过Iterator迭代器来遍历,事实也确实是这样,下面是Iterable接口中的三个方法:三、Iterator迭代器Iterator也是一个接口,使用迭代器可以遍历任何类型的集合,它有三个迭代器必备的基本方法外加jdk1.8新增了一个方便的遍历集合的方法forEachRemaining:四、List接口Java的List是非常常用的数据类型。List是有序的Collection。JavaList常用的实现类比如:ArrayList、Vector和LinkedList等。1、ArrayListArrayList是最常用的List实现类,内部是通过Object数组实现的,它可以实现对元素进行快速随机访问。ArrayList中的Object数组的的默认初始化大小是10,并且默认构造方法中对于数组的创建是在第一次添加元素时执行的,我们可以看看源码的实现:ArrayList添加元素的方法ArrayList会在添加新元素之前先检查一下数组的大小是否够用,如果不够用就会扩容,默认的扩容大小是原数组的1.5倍,如果扩容1.5的大小还是没法满足添加元素所需的容量,就会使用传入的容量大小,这样就会导致下一次添加元素的时候又触发扩容,因此在使用任何集合元素的时候请尽量给定一个初始容量,避免频繁的扩容影响性能。但是当从ArrayList的中间位置插入或者删除元素时,需要对数组进行复制、移动、代价比较高。因此,它适合随机查找和遍历,不适合插入和删除。2、Vector(数组实现、线程同步)Vector与ArrayList一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问ArrayList慢。3、LinkedList(双链表、双端队列)LinkedList即实现了List接口,同时它也实现了Deque接口。它**使用链表结构存储数据,很适合数据的动态插入和删除。**具体的我们看看源码就清楚了:**LinkedList添加元素的方法**LinkedList同时具有链表和双向队列的功能,因此添加元素的方法也有两个add方法和offer方法,前者用于向链表中尾插数据,后者用于向队列尾部添加数据。其实在源码中offer方法是把add方法有包装了一下而已。我们直接来看add方法的实现五、Set接口Set注重独一无二的性质,该体系集合用于存储无序(存入和取出的顺序不一定相同)元素,值不能重复。对象的相等性本质是对象hashCode值(java是依据对象的内存地址计算出的此序号)判断的,如果想要让两个不同的对象视为相等的,就必须覆盖Object的hashCode方法和equals方法。1、HashSet哈希表边存放的是哈希值。HashSet存储元素的顺序并不是按照存入时的顺序(和List显然不同)而是按照哈希值来存的所以取数据也是按照哈希值取得。元素的哈希值是通过元素的hashcode方法来获取的,HashSet首先判断两个元素的哈希值,如果哈希值一样,接着会比较equals方法如果equls结果为true,HashSet就视为同一个元素。如果equals为false就不是同一个元素。2、TreeSet(1)TreeSet()是使用二叉树的原理对新add()的对象按照指定的顺序排序(升序、降序),每增加一个对象都会进行排序,将对象插入的二叉树指定的位置。(2)Integer和String对象都可以进行默认的TreeSet排序,而自定义类的对象是不可以的,自己定义的类必须实现Comparable接口,并且覆写相应的compareTo()函数,才可以正常使用。(3)在覆写compare()函数时,要返回相应的值才能使TreeSet按照一定的规则来排序(4)比较此对象与指定对象的顺序。如果该对象小于、等于或大于指定对象,则分别返回负整数、零或正整数。3、LinkedHashSet对于LinkedHashSet而言,它继承与HashSet、又基于LinkedHashMap来实现的。LinkedHashSet底层使用LinkedHashMap来保存所有元素,它继承与HashSet,其所有的方法操作上又与HashSet相同,因此LinkedHashSet的实现上非常简单,只提供了四个构造方法,并通过传递一个标识参数,调用父类的构造器,底层构造一个LinkedHashMap来实现,在相关操作上与父类HashSet的操作相同,直接调用父类HashSet的方法即可。六、Map接口1、HashtableHashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。2、HashMapHashMap根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。我们用下面这张图来介绍HashMap的结构。在JDK7中HashMap使用数组+链表实现大方向上,HashMap里面是一个数组,然后数组中每个元素是一个单向链表。上图中,每个绿色的实体是嵌套类Entry的实例,Entry包含四个属性:key,value,hash值和用于单向链表的next。capacity:当前数组容量,始终保持2^n,可以扩容,扩容后数组大小为当前的2倍。loadFactor:负载因子,默认为0.75。threshold:扩容的阈值,等于capacity*loadFactorJava8对HashMap进行了一些修改,最大的不同就是利用了红黑树,构成了以数组+链表+红黑树的基本数据结构组合。根据Java7HashMap的介绍,我们知道,查找的时候,根据hash值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为O(n)。为了降低这部分的开销,在Java8中,当链表中的元素超过了8个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为O(logN)。具体的原理可以参考我的博客HashMap是如何工作的。3、LinkedHashMapLinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。4、TreeMapTreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。参考通过分析JDK源代码研究TreeMap红黑树算法实现七、安全失败机制(fail-safe)和快速失败机制(fail-fast)1、快速失败机制fail-fastfail-fast是java集合的一种错误检测机制,在遍历集合的时候当对集合的结构进行修改的时候,就会触发fail-fast。比如我们使用迭代器遍历集合的时候,如果在遍历过程中对集合的结构进行了修改(比如删除元素,增加元素),就会抛出ConcurrentmodificationException异常。比如像这样的情况:执行后结果:其实不光使用Iterator在遍历的时候不能修改集合元素的个数,增强for、forEach()方法等都不可以再遍历的增加或删除元素。原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个int类型的变量modCount统计修改的次数,他表示的是集合在遍历之前已经修改过的次数,每当删除或添加元素之后都会把modCount+1,此时当调用hasNext()/next()方法时会判断modCount和exceptedModCount的值是否相等,如果相等就返回继续遍历,否则抛出ConcurrentModificationException,终止遍历。应用场景:java.util包下的集合类使用了这种机制(Hashtable除外,它使用的是fail-safe),不能在多线程下发生并发修改(迭代过程中被修改)算是一种安全机制吧。如何避过快速失败机制,在遍历的时候修增加或减少集合元素?(1)在单线程环境下的解决办法其实很简单,细心的朋友可能发现在Itr类中也给出了一个remove()方法. 将上述代码改为下面这样就不会报错了:(2)在多线程环境下的解决办法上面的解决办法在单线程环境下适用,但是在多线程下适用吗?看下面一个例子:执行结果:有可能有朋友说ArrayList是非线程安全的容器,换成Vector就没问题了,实际上换成Vector还是会出现这种错误。原因在于,虽然Vector的方法采用了synchronized进行了同步,但是实际上通过Iterator访问的情况下,每个线程里面返回的是不同的iterator,也即是说expectedModCount是每个线程私有。假若此时有2个线程,线程1在进行遍历,线程2在进行修改,那么很有可能导致线程2修改后导致Vector中的modCount自增了,线程2的expectedModCount也自增了,但是线程1的expectedModCount没有自增,此时线程1遍历时就会出现expectedModCount不等于modCount的情况了。因此一般有2种解决办法:1)在使用iterator迭代的时候使用锁进行同步;2)使用并发容器CopyOnWriteArrayList代替ArrayList和Vector。2、安全失败机制fail-safe采用安全失败机制的集合容器,在遍历的时候不会直接在原来的内容量上遍历,而是复制一份在副本上遍历。由于遍历是在拷贝的副本上进行的,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,因此不会引发ConcurrentmodificationException应用场景:J.U.C包下都是安全失败机制容器,可以在多线程环境下并发的修改和访问。缺点:基于拷贝内容的优点是避免了ConcurrentModificationException,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

    LoveIT 2020-04-22
    Java集合与容器
  • 高并发编程之JAVA中的阻塞队列

    高并发编程之JAVA中的阻塞队列

    队列是一种访问受限的线性数据结构,它有两个基本操作:在队列尾部加入元素和从队列头部移除元素。在我们日常开发中,经常用来并发操作数据。java中提供了一些应用比较广泛的特殊队列:一种是以ConcurrentLinkedQueue为代表的非阻塞队列;另一种是以BlockingQueue接口为代表的阻塞队列。一、初识阻塞队列在J.U.C包中提供的BlockingQueue很好的解决了多线程中如何高效安全“传输”数据的问题。通过这些高效并且线程安全的队列类,为我们快速搭建高质量的多线程程序带来极大的便利。本文详细介绍了BlockingQueue家庭中的所有成员,包括他们各自的功能以及常见使用场景。BlockingQueue核心方法:阻塞队列的线程安全是依赖ReentrantLock实现的,并且借助于Condition中的await()和signal()\signal()方法实现对线程的阻塞和唤醒。二、阻塞队列成员详细介绍1、ArrayListBlockingQueueArrayListBlockingQueue是一个用数组实现的有界阻塞队列,此队列按照先进先出(FIFO)的原则对元素进行排序。支持公平锁和非公平锁(默认情况下是非公平锁)。2、LinkedBlockingQueueLinkedBlockingQueue是一个用单链表实现的有界阻塞队列。此队列的默认长度和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。3、PriorityBlockingQueue一个支持线程优先级排序的无界队列,默认自然序进行排序,也可以自定义实现compareTo()方法来指定元素排序规则,不能保证同优先级元素的顺序。4、DelayQueueDelayQueue是一个支持延时获取元素的无界阻塞队列。队列基于PriorityBlockingQueue实现。队列中的元素必须实现Delayed接口,在创建元素是可以指定多久才能从队列中获取当前元素。DelayQueue可以用于缓存系统设计和定时任务调度这样的应用场景。5、SynchronousQueueSynchronousQueue是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。它支持公平访问队列。默认情况下线程采用非公共策略访问队列。当使用公平锁的时候,等待的线程会采用先进先出的顺序访问队列。6、LinkedTransferQueueLinkedTransferQueue是一个由链表实现的无界阻塞Transfer队列。相对于其他阻塞队列,LinkedTransferQueue多了transfer和tryTransfer方法7、LinkedBlockingDequeLinkedBlockingDeque是一个由双链表实现的双向阻塞队列。队列头部和尾部都可以添加和移除元素,多线程并发时,可以将锁的竞争最多降到一半。接下来重点介绍下:ArrayListBlockingQueue、LinkedBlockingQueue以及DelayQueue三、阻塞队列原理及应用阻塞队列的原理就是基于等待/通知机制实现的,即当生产者往满的队列里添加元素的时候会阻塞生产者,当消费者消费了队列中的一个元素后户通知生产者生产。只不过在阻塞队列中没有使用Obeject类中的wait()和notify(),而是使用了Conditon接口下的await()和signal()方法来实现了对某个具体的线程的挂起和唤醒,底层调用的是LockSupport类的静态方法prak()和unpark()方法实现的。其中Condition接口的实例是通过ReentrantLock的newCondition()方法得到的,实际的实现是在AQS中,接下来我们就以ArrayListBlockingQueue、LinkedBlockingQueue以及DelayQueue为重点来分析一下他们是如何实现的。1、Conditon接口在AQS中的实现在AQS中有一个内部类ConditonObject,它实现了Condition接口。我们主要就来看看await()和signal()方法的实现:(1)await()方法—用于阻塞当前线程(2)signal()方法—用于唤醒处在AQS等待队列头部的线程2、ArrayListBlockingQueue(1)参数以及构造方法:(2)添加元素的原理—add/offer/putadd方法最终调用的还是offer方法,offer方法最终调用了enqueue(Ex)方法。enqueue(Ex)方法内部通过putIndex索引直接将元素添加到数组items中,这里可能会疑惑的是当putIndex索引大小等于数组长度时,需要将putIndex重新设置为0,这是因为当前队列执行元素获取时总是从队列头部获取,而添加元素从中从队列尾部获取所以当队列索引(从0开始)与数组长度相等时,下次我们就需要从数组头部开始添加了接下来我们看一下put方法的实现:put方法是一个阻塞的方法,如果队列元素已满,那么当前线程将会被notFull条件对象挂起加到等待队列中,直到队列有空档才会唤醒执行添加操作。但如果队列没有满,那么就直接调用enqueue(e)方法将元素加入到数组队列中。offer,add在正常情况下都是无阻塞的添加,而put方法是阻塞添加。这就是阻塞队列的添加过程。说白了就是当队列满时通过条件对象Condtion来阻塞当前调用put方法的线程,直到线程又再次被唤醒执行。总得来说添加线程的执行存在以下两种情况,一是,队列已满,那么新到来的put线程将添加到notFull的条件队列中等待,二是,有移除线程执行移除操作,移除成功同时唤醒put线程,如下图所示:(3)移除元素-poll/removepoll方法,该方法获取并移除此队列的头元素,若队列为空,则返回null接下来在看看remove方法,它只会移除元素而不会返回元素,移除成功返回true,否则返回false。3、LinkedBlockingQueue(1)重要参数和构造方法(2)添加元素—offer/putoffer方法和put的差别就是offer方法是一个非阻塞的方法,如果添加成功返回true,添加失败了就返回false,不会阻塞的等待。take方法是一个阻塞的方法,如果添加失败了,会阻塞着等待。(2)元素出队—poll()/take()poll和take方法的区别主要还是前者在获取的时候如果发现队列是空的就会直接返回null不会阻塞,当时后者如果发现队列是空的就会阻塞当前线程。具体我们通过源码分析一下:4、DelayQueueDelayQueue的泛型参数需要实现Delayed接口,Delayed接口继承了Comparable接口,DelayQueue内部使用非线程安全的优先队列(PriorityQueue),并使用Leader/Followers模式,最小化不必要的等待时间。DelayQueue不允许包含null元素。Leader/Followers模式:有若干个线程(一般组成线程池)用来处理大量的事有一个线程作为领导者,等待事件的发生;其他的线程作为追随者,仅仅是睡眠。假如有事件需要处理,领导者会从追随者中指定一个新的领导者,自己去处理事件。唤醒的追随者作为新的领导者等待事件的发生。处理事件的线程处理完毕以后,就会成为追随者的一员,直到被唤醒成为领导者。假如需要处理的事件太多,而线程数量不够(能够动态创建线程处理另当别论),则有的事件可能会得不到处理。所有线程会有三种身份中的一种:leader和follower,以及一个干活中的状态:proccesser。它的基本原则就是,永远最多只有一个leader。而所有follower都在等待成为leader。线程池启动时会自动产生一个Leader负责等待网络IO事件,当有一个事件产生时,Leader线程首先通知一个Follower线程将其提拔为新的Leader,然后自己就去干活了,去处理这个网络事件,处理完毕后加入Follower线程等待队列,等待下次成为Leader。这种方法可以增强CPU高速缓存相似性,及消除动态内存分配和线程间的数据交换。(1)参数以及构造方法(2)offer()方法普通的poll()方法:如果延迟时间没有耗尽的话,直接返回nulltake()方法:5、阻塞队列的应用阻塞队列更据不同的类型适用的场景很多,比如ArrayListBlockingQueue、LinkedBlockingQueue、SynchronousQueue可以作为线程池的任务队列(线程池的任务队列应该尽量使用有界队列);还比如DelayQueue可以用于实现缓存系统设计和定时任务调度的场景.....;除过这些,还有其他生产者消费者的应用场景都可以使用合适的阻塞队列实现业务逻辑。

    LoveIT 2020-04-21
    Java集合与容器
  • 高并发编程之CopyOnWriteArrayList—源码剖析

    高并发编程之CopyOnWriteArrayList—源码剖析

    CopyOnWriteArrayList是J.U.C包下的一个并发容器,它是线程安全且读操作无锁的ArrayList,写操作(增删改)则通过将底层数组拷贝一份,更改操作全部在新数组上进行,是一种读写分离的并发策略,我们也可以称这种容器为"写时复制器",Java并发包中类似的容器还有CopyOnWriteArraySet。本文会对CopyOnWriteArrayList的实现原理及源码进行分析。一、实现原理我们都知道,集合框架中的ArrayList是非线程安全的,Vector虽是线程安全的,但由于简单粗暴的锁同步机制,性能较差。而CopyOnWriteArrayList则提供了另一种不同的并发处理策略(当然是针对特定的并发场景)。很多时候,我们的系统应对的都是读多写少的并发场景。CopyOnWriteArrayList容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。1、关键属性和构造方法在jdk1.8中CopyOnWriteArraylist的底层数据结构还是数组,它使用ReentrarntLock来保证线程安全2、添加元素—add()add()方法的逻辑非常简单,在添加元素之前先获取当前对象的锁,之后在原数组的基础上复制一个新数组并且长度+1,之后把新元素放入新增加的数组索引位置之后在把老数组的引用改为这个新数组的引用,解锁成功后就完成了添加元素的逻辑。3、删除操作看了添加操作,再来看看删除操作,和add方法逻辑大致相同,都是需要先获取锁,如果移除的数据在数组末尾免责直接复制原数组的[0-array.length-1]即可;如果要删除的元素不在数组末尾,那就会先new一个是原数组长度-1的新数组,然后使用System.arrayCopy把原数组中除要删除元素之外的其他元元素复制过去。4、获取元素—get()CopyOnWriteArrayList允许并发的读,就是因为他没有加任何锁,任何线程只要来了就会可以获取数据。为啥敢这么写呢?这是因为CopyOnWriteArrayList中的Object数组是被volatile保护着,volatile可以保证共享数据在多个线程之间的可见性,即任何线程去获取值都是最新的值。二、总结—CopyOnWriteArrayList优缺点分析优点:读操作性能很高,因为无需任何同步措施,比较适用于读多写少的并发场景。Java的list在遍历时,若中途有别的线程对list容器进行修改,则会抛出ConcurrentModificationException异常。而CopyOnWriteArrayList由于其"读写分离"的思想,遍历和修改操作分别作用在不同的list容器,所以在使用迭代器进行遍历时候,也就不会抛出ConcurrentModificationException异常了,这就是一种安全失败机制(并发容器几乎都是这种机制)。缺点:  缺点也很明显,一是内存占用问题,毕竟每次执行写操作都要将原容器拷贝一份,数据量大时,对内存压力较大,可能会引起频繁GC;二是无法保证实时性,Vector对于读写操作均加锁同步,可以保证读和写的强一致性。而CopyOnWriteArrayList由于其实现策略的原因,写和读分别作用在新老不同容器上,在写操作执行过程中,读不会阻塞但读取到的却是老容器的数据。

    LoveIT 2020-04-20
    Java集合与容器
  • 高并发编程之ConcurrentLinkedQueue和ConcurrentLinkedDeque—源码剖析

    高并发编程之ConcurrentLinkedQueue和ConcurrentLinkedDeque—源码剖析

    一、ConcurrentLinkedQueue简介在Java开发个过程中我们不可避免的会使用到一些集合类,比如ArrayList、HashMap等,这些集合类型在多线程环境下使用会有线程安全问题。虽然不同的集合类型都有他的线程安全版本,比如ArrayList不是线程安全的,Vector是线程安全。而保障Vector线程安全的方式,是非常粗暴的在方法上用synchronized独占锁,将多线程执行变成串行化。要想将ArrayList变成线程安全的也可以使用Collections.synchronizedList(Listlist)方法ArrayList转换成线程安全的,但这种转换方式依然是通过synchronized修饰方法实现的,很显然这不是一种高效的方式,同时,队列也是我们常用的一种数据结构,为了解决线程安全的问题,DougLea大师为我们准备了ConcurrentLinkedQueue这个线程安全的队列。从类名就可以看的出来实现队列的数据结构是链式。ConrrentLinkedQueue是一个单向链式结构的无界并发队列。它遵循队列的先进先出规则(FIFO),当我们添加一个元素的时候会添加到队列的尾部,当我们获取一个队列的时候会从队列头部获取。它采用CAS机制保证了对元素操作的线程安全性。ConcurrentLinkedQueue适用于"单生产,多消费"的场景,同一时刻,允许两个线程(一个消费者一个或多个生产者)同时执行。二、ConcurrentLinkedQueue的结构1、ConcurrentLinkedQueue的类图首先对ConcurrentLinkedQueue在类的继承关系上先有个整体认知:ConcurrentLinkedQueued由head结点和tail结点组成,每个节点(Node)由结点元素(item)和指向下一个结点(next)的引用组成,节点与节点之间通过next引用关联起来,从而组成了一张链表结构的队列。默认情况下head结点存储的元素内容为空,tail结点等于head节点()2、内部类NodeNode中有两个属性:(1)元素item,类型有泛型参数决定;(2)下一个节点的引用next。值的注意的是,他们都被volatile修饰了,这可以保证在多线程环境下的内存可见性。另外在Node的构造参数中我们可以看到,它调用了Umsafe类中的本地方法,这进一步保证了线程安全性。3、操作Node的几个CAS操作在队列进行出队入队的时候免不了对节点需要进行操作,在多线程就很容易出现线程安全的问题。可以看出在处理器指令集能够支持CMPXCHG指令后,在java源码中涉及到并发处理都会使用CAS操作,那么在ConcurrentLinkedQueue对Node的CAS操作有这样几个:上面这些方法,底层实际调用了Unsafe类的相关本地方法来实现的,具体的方法实现需要惨嚎hostpot源码了,所以就先看到这里了,目前为止我们需要知道:ConcurrentLinkedQueue是通过CAS+volatile来保证ConcurrentLinkedQueue的线程安全性的即可。三、入队操作—offer()入队操作就是将节点添加到队列的尾部的过程。让我们通过每个节点入队的快照来观察下tail节点的变化:入队主要就干两件事:(1)新建一个节点,并将新节点设置为当前队列尾部节点的下一个节点;(2)更新tail节点,如果tail节点的next节点不为空,则将入队节点设置为tail节点,如果tail节点的next节点为空,则将入队的结点设置为tail节点的next节点。不过我们需要注意的是:ConcurrentLinkedQueue当前的tail不一定指向队列真正的尾节点,因为在ConcurrentLinkedQueue中tail是被延迟更新的从源码角度来看,整个入队过程主要做两件事:第一是定位出尾结点;第二是使用CAS算法将入队节点设置为尾结点的后继结点,如果不成功则重试。四、出队操作—poll()出队列的就是从队列里返回一个节点元素,并清空该节点对元素的引用。让我们通过每个节点出队的快照来观察下head节点的变化:从图中可知,并不是每次出队的时候都会更新head节点,当head结点里有元素(item!=null)的时候,直接弹出head结点中的元素,而不会更新head节点。只有当head节点里没有元素的时候,出队操作才会更新head节点。这种做法可以减少使用CAS更新head节点带来的消耗,从而提高效率。头节点的元素,首先判断头节点元素是否为空,如果为空,表示另外一个线程已经进行了一次出队操作将该节点的元素取走,如果不为空,则使用CAS的方式将头节点的引用设置成null,如果CAS成功,则直接返回头节点的元素,如果不成功,表示另外一个线程已经进行了一次出队操作更新了head节点,导致元素发生了变化,需要重新获取头节点。五、HOPS设计通过上面对offer和poll方法的分析,我们发现tail和head是延迟更新的,两者更新触发时机为:tail节点的更新触发时机:当tail结点的next节点不为空的时候,会执行定位队列真正的队尾节点的操作,找到队尾节点后完成插入之后才会通过casTail进行tail更新;当tail指向的节点的下一个节点为null的时候,只插入节点不更新tail。head节点的更新触发时机:当head结点的item=null的时候,将会执行定位头结点的操作,定位到头结点头使用CAS完成对头节点的删除,并且通过updateHead进行head更新;当head指向的节点的item域不为null的时候,只删除节点不更新head。如果让tail永远作为队列的队尾节点,实现的代码量会更少,而且逻辑更易懂。但是,这样做有一个缺点,如果大量的入队操作,每次都要执行CAS进行tail的更新,汇总起来对性能也会是大大的损耗。如果能减少CAS更新的操作,无疑可以大大提升入队的操作效率,所以douglea大师每间隔1次(tail和队尾节点的距离为1)进行才利用CAS更新tail。对head的更新也是同样的道理,虽然,这样设计会多出在循环中定位队尾节点,但总体来说读的操作效率要远远高于写的性能,因此,多出来的在循环中定位尾节点的操作的性能损耗相对而言是很小的。六、ConcurrentLinkedDeque在J.U.C包下还有一个非阻塞队列的实现就是ConcurrentLinkedDeque,它和ConcurrentLinkedQueue保证线程安全的机制是一样的,都采用CAS+volatile来实现。但是他与ConcurrentLinkedQueue的区别如下:ConcurrentLinkedQueue是单向链表结构的无界并发队列。元素操作按照FIFO(first-in-first-out先入先出)的顺序。适合“单生产,多消费”的场景。ConcurrentLinkedDeque是双向链表结构的无界并发队列。与ConcurrentLinkedQueue的区别是该队列同时支持FIFO和FILO两种操作方式,即可以从队列的头和尾同时操作(插入/删除)。适合“多生产,多消费”的场景。ConcurrentLinkedDeque的Node中不仅有next引用指向下一个节点,还有一个prev引用指向前一个节点(和AQS的结构类似)。明白这一点,在去理解ConcurrentLinkedDeque的设计就不困难了。

    LoveIT 2020-04-19
    Java集合与容器
  • 高并发编程之CnocurrentHashMap—源码剖析

    高并发编程之CnocurrentHashMap—源码剖析

    面试官:HashMap在多线程环境下存在线程安全问题,那你⼀般都是怎么处理这种情况的?一般在多线程的场景,我都会使用好几种不同的方式去代替:使用Collections.synchorinzedMap构造一个线程安全的HashMap使用Hashtable使用ConcurrentHashMap不过鉴于要保证多线程环境下程序的并发度,后两种方案直接Pass,因为他们保证线程安全的机制是synchronized,在并发很高的环境下效率很低,此时我们就可以使用ConcurrentHashMap。Collections.synchorinzedMap实现线程安全原理简单分析Collections.synchorinzedMap和Hashtable实现线程安全的手段是一样的,都是使用synchronized关键字实现的。Collections.synchorinzedMap实现线程安全的方式是在SynchronizedMap中维护了一个Map对象和一个互斥锁对象mutex我们在调⽤这个⽅法的时候就需要传⼊⼀个Map,可以看到有两个构造器,如果你传⼊了mutex参数,则将对象排斥锁赋值为传⼊的对象。如果没有传入metux参数,则将对象排斥锁赋值为this,即调⽤synchronizedMap的对象,就是上⾯的Map。创建出synchronizedMap之后,再操作map的时候,就会对⽅法上锁,如图一眼望去全是锁,你说这效率能高吗?使用synchronized锁住HashMap方法的调用,这样保证之后线程在调用HashMap方法之前必须获得SynchronizedMap中的互斥锁对象的锁才能继续执行,否则只会等待,从而实现了线程安全,其实实质和Hashtable一样,在高并发环境下不推荐使用,要用线程安全的Map还是得用ConcurrentHashMap。一、jdk1.7的实现以及源码分析Java7的ConcurrentHashMap里有多把锁,每一把锁用于其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率.这就是“分段锁”技术。1、ConcurrentHashMap的数据结构jdk1.7的ConcurrentHashMap的底层数据结构是数组+链表,ConcurrentHashMap是主要由Segment数组结构和HashEntry数组结构组成。如下图所示。整个ConcurrentHashMap由一个个Segment组成,Segment代表“部分”或“一段”的意思,所以很多地方都会将其描述为分段锁。简单的理解,ConcurrentHashMap是一个Segment数组,Segment通过继承ReentrantLock来进行加锁,所以每次需要加锁的操作锁住的是一个Segment,这样只要保证每个Segment是线程安全的,也就实现了全局的线程安全性。每个Segment中维护了一个HashEntry数组,它用于存储键值对。Segment继承了ReentrantLock,是一个可重入的锁,它在ConcurretnHashMap中扮演锁的角色;HashEntry则用于存储键值对。它们之间的关系是:一个ConcurrentHashMap包含了一个Segment数组,一个Segment里维护了一个HashEntry数组,HashEntry数组和HashMap结构类似,是一种数组+链表的结构,当一个线程需要对HashEntry中的元素修改的时候,必须先获得Segment锁。下面是ConcutrrentHashMap的继承体系:ConcurrentHashMap重要成员变量和常量2、Segment内部类首先先来熟悉一下Segment,Segment是ConcurrentHashMap的内部类,它在ConcurrentHashMap就是扮演锁的角色,主要组成如下:以及还有一个常量:HashEntry是一个和HashMap类似的数据结构,这里值的注意的是他被volatile修饰了。这里复习一下volatile的特性:(1)可以保证共享变量在多线程之间的内存可见性,即一个线程修改了共享变量的值对于其他线程是这个修改后的是可见的;(2)可以禁止指令重排序;(3)volatile可以保证对单次读写操作的原子性;这里使用volatile修饰HashEntry数组的目的当然是为了保证内存的可见性问题。为什么需要保证HashEntry数组的内存可见性呢?在往下看源码就会发现,jdk1.7以前的CHM的get操作是不需要加锁的,即可以并发的读,只有在写数据的时候才需要加锁,因此为了保证不同线程之前对于一个共享变量的数据一致,使用volatile再好不过。3、ConcurrentHashMap的构造方法ConcurrentHashMap的构造方法中通过initialCapacity、loadFactor、concurrencyLevel三个参数完成了对Segment数组、段偏移量segmentShift、段掩码segmentMask和每一个Segment里的HashEntry数组的初始化。我们来看看源码是如何实现的:初始化initialCapacity:初始容量,这个值指的是整个ConcurrentHashMap的初始容量,实际操作的时候需要平均分给每个Segment。loadFactor:负载因子,之前我们说了,Segment数组不可以扩容,所以这个负载因子是给每个Segment内部使用的。concurrencyLevel:并行级别、并发数、Segment数。默认是16,也就是说ConcurrentHashMap默认有16个Segment,所以理论上,最多同时支持16个线程并发写。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它就不可以扩容了。最大值受MAX_SEGMENTS控制,为65535个。newConcurrentHashMap()无参进行初始化以后:(1)Segment数组的长度是16,并且segments[0]初始化了,其他segments[i]还是null,在使用之前需要调用ensureSegment()方法执行初始化(2)segments[i]的默认大小是2,也即HashEntry的默认大小是2,负载因子为0.75,得出初始阈值为1.5,也就插入第一个元素不会触发扩容,插入第二个进行一次扩容。4、定位SegmentConcurrentHashMap使用了分段锁Segment来维护不同段的数据,那么在插入和获取元素的时候,必须先通过算法首先定位到Segment上之后才可以在具体的HashEntry用类似HashMap找元素的方法来定位一个元素。首先来看看ConcurretnHashMap使用的hash算法:通过这个hash算法对hashCode再散列,可以看到进行了非常复杂的移位运算,目的就是减少hash冲突,使元素可以均匀的分布在Segment数组内,提高容器的使用率。基于这个hash(inth)计算出来的hash值,Segment通过下面这个算法定位到具体的Segment:通过上面的分析我们知道,在默认情况下segmentShift是28,segmentMask是15,因此hash>>>segmentShift的意思就是只让hash值的高4位参与到定位Sengmet运算中,上面说到过,segmentMask是散列运算的掩码,它等于ssize-1,ssize又是一个2的幂的数,因此segmentMask二进制的低位是连续的1,那么最终决定Segment位置索引的就是hash>>>segmentShift的值。5、put()方法put流程梳理:(1)首先检查value,如果value==null,抛出NPE;(2)根据hash值定位到Segment,定位segments[j]的算法是:j=(hash>>>sgmentShift)&sgmentMask,其实j就是hash值的低4位(3)获得到Segment后判断是否为null,如果是null,表示还没有初始化,那先调用ensureSegment()方法初始化Segment(4)最后,调用Segment对象的put方法存入键值对。4.1、首先通过父类ReentrantLock的tryLock()方法对每个Segment加锁,上锁成功之后继续执行,否则调用scanAndLockForPut()方法不断的调用tryLock()尝试获取锁,尝试的次数和CPU核数有关,单核CPU值尝试1次,多核CPU最多重试64次,如果重试结束还没有获取锁,那就阻塞等待锁;4.2、确定HashEntry[i]的下标。index=(tab.length-1)&hash;4.3、如果当前HashEntry没有初始化,先初始化,并将键值对插入;4.4、已经初始化,遍历HashEntry链,有重复的,新值覆盖旧值,否则,插入;4.5、插入之后,判断是否需要扩容,扩容:Segment[]是不能扩容的,只能扩容一个个Segment中的HashEntry[]的大小为原来的两倍Segment内部的put()方法下面是Segment内部put方法的源码,比较重要,我这里把它列出来并写上详细的注释,大家可以参考学习。scanAndLockForPut()——获取写入锁前面我们看到,在往某个segment中put的时候,首先会调用**tryLock()**方法尝试获取锁如果获取成功就继续执行put逻辑,否则调用scanAndLockForPut(key,hash,value)。下面我们来具体分析这个方法中是怎么控制加锁的。这个方法有两个出口,一个是tryLock()成功了,循环终止,另一个就是重试次数超过了MAX_SCAN_RETRIES,进到lock()方法,此方法会阻塞等待,直到成功拿到独占锁。这个方法就是看似复杂,但是其实就是做了一件事,那就是获取该segment的独占锁,如果需要的话顺便实例化了一下node。6、rehash()扩容方法重复一下,Segment数组不能扩容,扩容的是单个Segment内部数组HashEntry[],扩容后,容量为原来的2倍。触发扩容的时机:put的时候,如果判断该值的插入会导致该Segment内部的元素个数超过阈值,那么先进行扩容,再插值。该方法不需要考虑并发,因为put操作,是持有独占锁的。7、get()方法Segment的get操作实现非常简单和高效。先经过一次hash(),然后使用散列值运算定位到Segment,在定位到聚义的元素的过程。get操作的高效是因为整个get操作不需要加锁,为什么他不需要加锁呢?是因为get方法中使用的共享变量都被定义成了volatile类型,比如:统计当前Segment大小的count,和用于存储key-value的HashEntry。定义成volatile的变量能够在多个线程之间保证内存可见性,如果与多个线程读,它读取到的值一定是当前这个变量的最新值。get操作流程:(1)计算hash值,找到Segment数组中的下标。(2)再次根据hash找到每个Segment内部的HashEntry[]数组的下标。(3)遍历该数组位置处的链表,直到找到相等(==||equals)的key。get是不需要加锁的:原因是get方法是将共享变量(table)定义为volatile,让被修饰的变量对多个线程可见(即其中一个线程对变量进行了修改,会同步到主内存,其他线程也能获取到最新值),允许一写多读的作。8、size()方法size()方法就是求当前ConcurrentHashMap中元素实际个数的方法,它是弱一致性的。方法的逻辑大致是:首先他会使用不加锁的模式去尝试计算ConcurrentHashMap的size,比较前后两次计算的结果,结果一致就认为当前没有元素加入或删除,计算的结果是准确的,然后返回此结果;如果尝试了三次之后结果还是不一致,它就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回。二、jdk1.8的实现以及源码分析1、ConcurrentHashMap的数据结构JDK1.8的实现已经摒弃了Segment的概念,而是直接使用数组+链表+红黑树的数据结构来实现的,并发控制使用的是CAS+synchronized,jdk1.8版本的ConcurrentHashMap看起来就像是优化过之后线程安全的HashMap,虽然在JDK1.8中还能看到Segment的身影,但是已经简化了属性,只是为了兼容旧版本。下图是jdk1.8中ConcurrentHashMap的结构示意图:结构上和Java8的ConcurrentHashMap和HashMap基本上一样,不过它要保证安全性,所以源码上确实要复杂一些2、ConcurrentHashMap的属性和初始化(1)重要属性(2)初始化根据initCapacity和加载因子loadFactory计算出size,然后寻找size的最近的2的幂,如果size的最大值是MAX_ARRAY_SIZE即:Integer.MAX_VALUE-8。另外我们可以观察到,ConcurrentHashMap是懒惰初始化的,构造方法中只是将初始容量计算出来保存在sizeCtl变量中。再往下阅读源码会发现,他是在第一次put的时候执行哈希表的初始化的。内部类NodeNode是普通的哈希槽位结点或链表结点类型,保存了一个节点的hash、key、value、以及下一个结点。和HashMap在jdk1.8的实现类似。下面是源码。内部类TreeNodeTreeNode是红黑树的结点,当哈希槽位链表长度大于8并且哈希表的大小大于64的时候,链表就会转化为红黑树,一个TreeNode界定中存储关于它的父节点parent、左孩子left、右孩子right、以及结点的红黑标记,下面是源码。3、添加元素操作—put()put()方法流程梳理:(1)首先我们通过源码看到,jdk1.8中ConcurrentHashMap不允许key或value为null,如果你违反了就会报NPE。(2)之后获取到hash值,在之后会首先判断当前hash表是否被初始化了,因为jdk1.8中ConcurrentHashMap是懒惰初始化的,只有第一次put的时候才会初始化。初始化会调用initTable方法,该方法中使用CAS设置seizeCtl的值来保证线程安全。(3)如果哈希表已经初始化了,然后计算key的哈希桶的位置,如果在这个位置还是null,那就直接使用CAS设置新节点到桶的这个位置即可;定位桶的算法还是i=(tab.length-1)&hash(4)如果检查发现当前槽位的头结点的hash值==-1(标记为是ForwardingNode结点了),表示当前hash表正在扩容中,此时其他线程过去帮忙扩容;(5)如果当前hash表存在、(n-1)&hash位子有元素了并且没有在扩容,那就是发生了哈希冲突,解决冲突之前首先对链表的头结点/红黑树的root结点上锁5.1、如果key的桶的位置还是链表,那就在链表的尾部插入新的元素,再次过程中还会统计链表的结点个数,存储在bitCount中,用于在插入之后判断是否需要扩容以及检查是否相同key的结点存在,如果存在那就执行值替换逻辑;5.2、如果key的桶的位置已经是红黑树了,那就把新的元素插入红黑树中(6)插入完毕之后会判断binCount的值是否已经大于等于树化阈值(TREEIFY_THRESHOLD=8)了,如果是,那就把链表转换为红黑树,不过最后转不转得成还要看哈希表的容量是不是大于MIN_TREEIFY_CAPACITY(64),如果没有达到只会扩容下面是put方法的源码,非常重要,我做了详细的注释,大家可以参考并理解。初始化哈希表—initTable()在put过程中如果是第一次put,此时hash表还没有初始化,因此需要首先初始化哈希表。初始化哈希表会调用initTable()方法,这个方法会使用CAS加锁,然后实例化一个hash表我们一起来看看hash表的初始化逻辑吧!统计链表的长度—addCount()在put操作的最后一个操作中他会调用addCount方法来统计哈希表的使用情况,如果已经超过阈值了就会扩容。具体请看注释分析。4、获取元素操作—get()get()方法没有加锁,即可以并发的读,敢这么做重要是因为Node数组被volatile被修饰了,volatile保证了共享变量在多线程下的内存可见性,即其他线程只要一修改Node数组中的数据,get操作的线程马上就可以知道修改的数据。get()方法逻辑梳理:(1)计算出key的hash值;(2)根据hash值找到哈希槽的位置:(n-1)&hash(3)根据该位置处头节点的性质进行查找:1)如果该位置为null,那么直接返回null就可以了2)如果该位置处的节点刚好就是我们需要的,返回该节点的值即可3)如果该位置节点的hash值小于0,说明正在扩容,或者是红黑树,后面调用find接口,程序会根据上下文执行具体的方法。4)如果以上3条都不满足,那就是链表,进行遍历比对即可5、获取元素的个数—size()6、扩容操作—transfer()扩容的逻辑比较复杂,这里不再贴出源码了,我们来捋一下关键流程就好了:首先扩容会传进来两个参数:旧哈希表和新哈希表(Node<K,V>[]tab,Node<K,V>[]nextTab),在第一个线程调用这个方法的时候新哈希表nextTab=null当线程判断nextTab=null之后,会new一个是旧hash表2倍大小的新哈希表(Node<K,V>[]nt=(Node<K,V>[])newNode<?,?>[n<<1];)创建新哈希表完成之后就是元素结点的搬迁工作了。这个过程中值的注意的是,当一个线程把旧哈希表上桶中的元素搬迁到新的哈希表上之后或者这个桶中没有元素,它用一个ForwardingNode类型的结点挂在这个桶下,以此告诉其他线程这个桶位置已经被处理了,这样可以避免其他线程get或put时出现错误还需要注意的是,在移动桶中元素的过程中,还是需要对链表和红黑树分别处理,前者的判断依据是头结点的hash值大于等于0,后者的判断依据是头结点的hash值小于0并且头结点是TreeBin类的实例。参考资料【1】JAVA7与JAVA8中的HASHMAP和CONCURRENTHASHMAP知识点总结【2】Java7/8中的HashMap和ConcurrentHashMap

    LoveIT 2020-04-19
    Java集合与容器
  • HashMap是如何工作的

    HashMap是如何工作的

    一、认识HashMapHashMap最早在jdk1.2就出现了,直到jdk1.7都没有太大的改动:jdk1.7及以前采用的存储结构是采用了数组+链表,jdk1.8的存储结构是数组+链表+红黑树HashMap的底层其实就是一个哈希表,通过哈希表可以实现O(1)的查询效率,HashMap允许存储一个key-value都为null的元素(这一点Hashtable不可以),HashMap在多线程环境下没法保证对元素的操作是线程安全的。下面我们就来一步步分析,并针对特定问题给出对应的解决方法。二、深入分析HashMap1、底层数据结构在jdk1.7以前HashMap的底层数据结构是数组+链表的存储结构:在jdk1.8以后采用了数组+链表+红黑树的存储结构:在HashMap的数组里存储着Key-Value这样的实例,在jdk1.7以前叫Entry,jdk1.8以后叫的Node,名字变了,但是本质一样。每一个节点都会保存自身的hash、key、value、以及下个节点,我看看Node的源码。2、HashMap的属性和构造方法重要的属性构造方法这里有几个点需要关注:阿里Java编程规范中为什么要求指定HashMap的容量?HashMap扩容是非常非常消耗性能的,Java7中每次扩容先创建2倍当前容量的新数组,然后再将老数组中的所有元素再次通过hash&(length-1)的方式重新散列到新的数组中;Java8中HashMap虽然不需要重新根据hash值散列后的新数组下标,但是由于需要遍历每个Node数组的链表或红黑树中的每个元素,根据元素key的hash值与原来老数组的容量的关系来决定放到新Node数组哪一半(2倍扩容),还是需要时间的;具体的扩容原理可以参考下面resize()的解析。总之就是一句话:随着数据量的快速增加,HashMap会发生多次扩容,而HashMap中的扩容机制决定了每次扩容都需要重建hash表,是非常影响性能的。HashMap指定容量初始化后,底层Hash数组已经被分配内存了吗?通过上面的HashMap的构造方法的源码可以知道,HashMap在指定容量之后并没有直接给底层的Node数组分配空间。在jdk1.8中真正分配空间是在第一次put元素的时候由resize()方法执行的,下面会详细说到。HashMap是如何保证用户输入任意初始容量最终HashMap的容量始终是2的整数幂的?既然都说道这了,我们不妨来看看tablSizeFor()方法的实现吧!这个方法的作用就是返回一个给定数字的最小的2的幂。下面我们来分析一下这个方法:为什么要对给定的容量cap-1呢?这是为了防止原本给定的容量就是2的幂了,那么进过下面5次右移就会把容量扩大为原来的2倍。举个例子,比如传了一个容量值为10:最后判断一下,发现n不小于0并且n不大于最大容量就把n+1即:(00001111)+1===>(00010000),也就是2^4=16。3、存储元素的put()方法通常我们会使用HashMap的put方法网HashMap中存数据,那么在我们调用了put()之后发生了那些事情呢?我们打开源码看看:可以看到,put()方法只是对putVal()方法进行了一次包装,putVal()有几个形参,从左向右依次是:key的hash值、key、value、是否仅允许key不存在再存放。HashMap中计算key的hash值的方法jdk1.8中hash()算法的实现:对比jdk1.7中的计算key的hash算法的方法在这两个jdk版本中的hash()算法的实现是不同的,但基本逻辑没有变:都是先获取了key的hashCode,然后对这个hashCode进行了移位和异或运算。HashMap的hash值是如何计算的?为什么要这么计算?JDK1.8中,hash值是通过对key的hashCode()的高16位异或低16位实现的:(h=k.hashCode())^(h>>>16)。主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。对比发现jdk1.7中的hash()算法的移位操作比jdk1.8中的复杂,这样设计是为什么呢?因为在jdk1.8中引入了红黑树后对于hash()算法就不需要有那么高的要求了,就算是不够散列也有红黑树来兜底,但是jdk1.7没有红黑树,为了保证元素的散列性,hash()算法应该尽可能的将key的hashCode的每一位都让他参与到计算中,即只要key的hashCode中任何一位发生变化就会对最终的结果产生影响。拿到hash值之后,就到了putVal()中真正的处理元素了。下面是putVal()的源码:putVal()方法的逻辑:第一个if处判断当前的table数组是否为空,如果为空就进入resize()方法初始化一个table数组。在jdk1.8中resize()方法不仅可以扩容,还可以初始化table数组。第二个if是判断在table[i]位置是否有元素,如果没有就直接new一个Node放进去即可。这里需要注意的是在计算i这个值的时候采用的算法:(n-1)&hash,为什么要这么做呢?通常我们实现的Hash表都是采用hash%数组长度,并且数组长度我们建议最好是质数。在这里为什么不用%操作呢?原因如下:首先这么做肯定可以达到和取模一样的效果,并且位操作&的效率更高n表示table当前的容量,但是我们知道table的容量是2的幂,把这个数-1就得到了一个低位连续是1的一个数(二进制表示),此时再做按位与,数组的索引值就完全取决于hash值了。进入了第三个条件就是说明当前table存在但是table[i]的位置发生了hash碰撞。接下来就是解决hash碰撞的以及当链表长度操作8之后转化为红黑树的逻辑了:在第三个条件中又存在三个条件:第一个条件判断插入的新值是否在HashMap中已经存在,如果是的,就会把这个值先取出来,然后在最后的一个if判断中进行修改第二个条件就说明插入的新值在HashMap中不存在,此时判断当前链表是否已经树化了,如果被树化就调用putTreeVal()把新值修改到红黑树中第三个条件是插入的新值在HashMap中不存在并且链表还没有树化,可以看到,jdk1.8中采用了尾插法把新的结点插入的(这里一定要注意,jdk1.7采用的是头插法)。并且在插入的过程中还在不断的监控链表的长度,如果链表的长度一旦>=8了就立即调用treeifyBin()方法将链表转换为红黑树最后在插入成功之后会进行比较++size>threshlid是否成立,如果成立说明需要扩容了。总结一下put()方法的流程:(1)通过put()方法传入key-vlaue,并调用hash函数计算出key的hash值,jdk1.8中的hash函数是如果key是null,那么hash=0,即key为null的元素将存储在Hash表的第一个位置;否则hash=(h=key.hashCode())^(h>>>16)(2)调用到putVal(...)方法中,首先判断当前Hash表是否初始化了,如果没有初始化则会调用resize()方法执行初始化,否则进入下一步;(3)根据hash值计算出key-value键值对的槽位(i=(n-1)&hash),如果槽位table[i]还没有元素,那就直接新添加的key-value键值对实例化成Node添加到哈希表中;如果槽位table[i]已经有元素了,那就进入下一步;(4)如果发生了hash冲突,那就要还要判断此时处理冲突的情况是什么如果哈希表中的key和待插入的新元素的key相等并且它们的equals()方法相等,那么就会执行元素值替换操作;否则如果此时槽位已近变成了红黑树了,那就调用**putTreeValur()**把值插入到红黑树中若此时的数据结构是链表,从链表头遍历每个结点,如果key相等并且equals()方法相等那就执行值替换操作,否则执行尾插,在链表尾部插入新元素,插入之后判断链表的长度是否大于等于树化阈值8,如果达到了树化阈值将会调用treeifyBin()执行树化逻辑,最后能不能树化,还需要看当前哈希数组的长度是否大于最小树化容量64,如果达不到那就不会执行树化逻辑而是执行扩容逻辑;(5)最后当map实际数大小等于threshold容量的阈值时,会进行两倍扩容最后结合流程图再来理解一下put()方法的流程:了解了HashMap的put流程,我们来看一下常见的额面试问题是咋问题的(~~刁难的~~)?HashMap为什么只有当链表长度大于8并且哈希数组的容量大于64时才转红黑树?红黑树的插入、删除和遍历的最坏时间复杂度都是O(lgn),因此,意外的情况或者恶意使用下导致hashcode()方法的返回值很差时,只要Key具有可比性,性能的下降将会是"优雅"的。但由于TreeNodes的空间占用是常规Nodes的两倍,所以只有桶中包含足够多的元素以供使用时,我们才应该使用树。那么为什么这个数字会是8呢?看HashMap源码中的一段注释:理想情况下,在随机哈希代码下,桶中的节点多少的频率遵循泊松分布,上图展示了桶长度k的频率表。由频率表可以看出,同一个Hash桶位冲突达到8的概率非常非常小(千万分之六)。所以作者应该是根据概率统计而选择了8作为阀值,由此可见,这个选择是非常严谨和科学的。红黑树简单介绍红黑树(RedBlackTree)是一个自平衡的二叉查找树,但是每个节点上增加一个存储位表示节点的颜色(红色或黑色),红黑树的查找、删除、插入操作最坏的时间复杂度都可以达到O(lgn)。红黑树是牺牲了严格的高度平衡的优越条件为代价,红黑树能够以O(log2n)的时间复杂度进行搜索、插入、删除操作。此外,由于它的设计,任何不平衡都会在三次旋转之内解决。红黑树的查询性能略微逊色于AVL(平衡二叉查找树)树,因为他比AVL树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的AVL树最多多一次比较;但是,红黑树在插入和删除上完胜AVL树,AVL树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于AVL树为了维持平衡的开销要小得多。总的来说,红黑树除了符合二叉查找树的基本特性外,还具有以下几个特性:红黑树的结点是红色的或黑色的;红黑树的根节点是黑色的;叶子节点(NULL结点)都是黑色的;每个红色节点的两个子节点都为黑色(红黑树不会出现相邻的红色节点);从任意节点出发,到其每个叶子节点的路径中包含相同数量的黑色节点;新加入到红黑树的节点为红色节点。为什么HashMap初始容量是1<<4?(1)首先用位运算而不直接写个16,这样是为了位运算的方便(比如后面扩容时的写法就是newCap=oldCap<<1),位运算比算数计算的效率高了很多。(2)再者使用16这种类型的数是为了摆脱在计算某个key的桶位的时候的使用取模运算带来的性能消耗,HashMap采用2的幂整数与Length-1按位与,既可以达到取模运算的效果,也可以简化运算提高效率;(3)因为在使用哈希表容量是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。4、Hash表的扩容resize()原理HashMap中有两种情况会触发扩容:Map的实际大小等于扩容阈值(threshold=加载因子*初始容量)的时,会执行2倍扩容;当hash表中某个桶的链表长度大于树化阈值(TREEIFY_THRESHOLD=8),但是hash数组的容量小于最小树化容量(MIN_TREEIFY_CAPACITY=64)时会执行2倍扩容;除了上面两种情况之外,还有一种情况也会调用resize()方法,但不是扩容逻辑——hash表的初始化是不是一看到这么长的源码就没有读下去的欲望了?不要怕,接下来我们分解一步步来:(1)确定新容量和新阈值或者初始化Hash表首先获取当前当前哈希表的长度,如果哈希表还未初始化那长度就是0;获取当前阈值threshold,并初始化新的容量newCap和新阈值newThr为0;接下来分三种情况计算出新容量和新阈值:1)如果当前哈表容量oldCap>0,并且当前哈希表的容量大于等于最大允许容量(MAXIMUM_CAPACITY=2^30),那就把阈值设为最大的整数,然后返回不允许再扩容了;否者将新的容量设置为当前容量的2倍,如果设置后新的容量<最大允许容量并且当前容量>=默认容量(16)就会把新的阈值也设置为当前阈值的2倍2)第二个成立表示哈希表还没初始化大事阈值初始化了(oldThr>0),此事会将新的容量设置为当前阈值。为什么呢?因为在构造方法中将算出来的容量设置给了阈值。3)最后一种情况就是哈希表和阈值都没有初始化,那就把新的容量设置为默认容量16,新的阈值设置为默认加载因子0.75*默认容量16=12随后判断一下新的阈值是否为0,如果为0那就根据容量和加载因子计算出一个新阈值后将阈值更新;最后更新阈值,并实例化一个newCap大小的Node数组,完成哈希表容量的扩大;(2)把当前哈希表中的数据复制到新哈希表中到这一步扩容动作已经完成了,现在Node数组已经是之前的2倍了,现在需要干的事情就是从老哈希表中将数据重新hash并散列到新表中。主要逻辑如下:核心思路:遍历老的哈希表,没有元素的槽位不管,只处理有元素的槽位;处理有元素的槽位的逻辑分为三大部分:1)如果这个槽位没有后继结点了(没有形成链表),就重新计算元素移动到新的哈希表中位置并直接移动过去,重新计算桶位的算法还是:e.hash&(newCap-1)2)如果槽位有后继结点并且已近树化了(已经是红黑树了),那就将红黑树拆分后,在新的哈希表中重新hash并散列到新的Node数组中3)如果槽位有后继结点但是没有树化(已经是链表了),那就还是用尾插法把元素移动到新的哈希表中,需要注意的是:A:扩容后,若e.hash&oldCap=0,那么元素在扩容后的位置=原始位置(newTab[j]=loHead)B:扩容后,若hash&oldCap!=0,那么元素在扩容后的位置=原始位置+旧数组大小(newTab[j+oldCap]=hiHead)。到这里HashMap的扩容机制原理大致清楚了后,我们来看一下常见的面试题如何提问的(~~刁难的~~)?HashMap的扩容分几个步骤?HashMap的扩容可以分为两个步骤:*根据老哈希表的大小计算出新哈希表的新容量以及新阈值,并创建一个新的Node空数组,长度是原数组的2倍*遍历原Entry数组,把所有的Entry重新Hash到新数组为什么要重新Hash呢,直接复制过去不香么?因为HashMap的元素槽位是通过(n-1)&hash计算出来的,其中n是HashMap的容量,扩容之后Map的容量发生了变化,因此需要重新计算元素的槽位为啥之前用头插法,java8之后改成尾插了呢?在jdk1.7中,当多线程同时扩容时,执行resize扩容时会调用transfer方法重新散列table散列元素为从头插入,这里容易形成环形链表,调用get方法或put方法遍历这个Hash数组的位置的链表,如果元素key不存在,就在这个循环链表中无限循环遍历了,因为停止循环的条件就是:尾结点的指针为null使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。总的来说就是:Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系,从而容易导致形成循环链表。Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。那是不是意味着Java8就可以把HashMap用在多线程中呢?我认为即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。5、Hash表获取元素get()原理调用get方法时传入key,首先还是调用hash函数计算出key的hash值(hash=(h=key.hashCode())^(h>>>16)):计算出key的hash值之后调用getNode(...)到Map中获取结点元素首先判断当前哈希表是否为空,如果为空直接返回null;如果哈希表不空,就会计算key的桶位table[(n-1)&hash],如果桶位为空还是会返回null;如果哈希表不为空并且key在HashMap中存在,那就首先检查一下hash表中的这个元素是不是要找的值(判定的标准是equals方法返回true),如果是就直接返回vlaue,如果不是还会在判断一下是否存在后继结点,如果不存在直接返回null如果存在后继结点,那么首先判断一下是红黑树还是链表,如果是红黑树那就到红黑树中取值,如果是链表那就从头开始遍历链表找到值后返回。最后关于HashMap我总结了一些常见的面试题,大家可以通过题目检测一下自己的学习成果。链接:关于HashMap几个刁钻的面试题,第四个我就跪了参考资料[1]rhyme:JAVA7与JAVA8中的HASHMAP和CONCURRENTHASHMAP知识点总结[2]敖丙:《我们一起进大厂》系列-HashMap

    LoveIT 2020-04-15
    Java集合与容器
  • 面试常问的几个Java并发工具类你都GET到了吗?

    面试常问的几个Java并发工具类你都GET到了吗?

    在JDK的并发中提供了几个非常有用的并发工具类:CountDwonLatch、CyclicBarrier、Semaphore。下面我们就一起了解一下这些类的基本使用以及基本原理吧。PS:最起码你得知道它们是干什么用得,下面列出得代码也建议你跑跑。其实如果你理解AQS的设计原理的话,这些工具类的原理就不难理解了。一、闭锁CountDwonLatchCountDownLatch允许一个或多个线程等待其他线程完成操作之后在进行下面的业务的场景。作用类似于Thread类中的join()方法,但是比join()方法更加强大。下面是一个简单的使用示例:执行结果为什么join()方法和他的作用类似还需要CountDownLatch?答:因为join()的阻塞原理是不断检查join()所属的线程是否存活,如果线程还存活这就继续阻塞,直到线程死亡了才会退出join()阻塞。但是在实际应用场景下我们一般都是使用线程池来维护线程资源的,怎么可能随意的让一个线程死亡,此时join()就没法使用了,CountdDownLatch就可以解决这个问题,CountDownLatch的阻塞原理是仅仅关注计数器是否为0,若为0才保持阻塞,它并不关注持有计数器的其它线程是否完全执行完毕。下面是一个CountDownLatch配合线程池使用的示例:执行结果CountDownLatch原理简单分析CountDownLatch是基于AQS实现的一个同步工具,大部分和同步状态有关的操作在AQS中已经被实现了,CountDownLatch采用在内部聚合AQS的方式,实现了对多个线程的同步控制。在CountDownLatch中有一个内部类Sync继承了AQS,下面是Sync的全部实现:我们可以看到Sync的构造方法可以接受一个int类型的参数,这个参数就是计数器的值,这个值最终通过setState()方法设置给了AQS中的一个控制同步队列锁状态的字段state。再来看看CountDonwLatch的构造方法就立刻明白了:在构造方法中直接new了一个Sync,并把我们传入的参数又传给了Sync。再来看看countDown()方法干了啥:可以看到,countDown()方法直接调用了同步器的releaseShared()方法,这个方法我的另一篇博客中深入源码分析AQS实现原理中解析过,在这个方法中会首先调用上面Sync子类中的tryReleaseShared()方法,在这个方法中会循环的把AQS中的state字段的值减1,直到减为0了就返回true,表示同步状态释放了。二、同步屏障CyclicBarrierCyclicBarrier([ˈsaɪklɪkˈbæriə(r)],同步屏障),作用和CountDownLatch类似,CyclicBarrier的构造方法同样也需要一个int类型的参数,当线程调用了CyclicBarrier.await()就进入阻塞。当阻塞的线程数达到了构造参数传入的数目时,所有进入等待状态的线程都被唤醒并继续执行。CyclicBarrier就象它名字的意思一样,可看成是个屏障,所有的线程必须到齐后才能一起通过这个屏障。CyclicBarrier初始时还可带一个Runnable的参数,此Runnable任务在CyclicBarrier的数目达到后,所有其它线程被唤醒前被执行。执行结果CyclicBarrier和CountDownLatch的区别?答:CyclicBarrier的计数器可以使用reset()方法重置为初始值,而CountownLatch的计数器是一次性的。不可以恢复。基于这一点,CyclicBarrier能处理更为复杂的业务场景,比如计算发生错误,可以重置计数器,并让线程重新执行一次。三、信号量SemaphoreSemaphore([ˈseməfɔːr],信号量),它可以设定一个阈值,基于此,多个线程竞争获取许可信号,做自己的申请后归还,超过阈值后,线程申请许可信号将会被阻塞。Semaphore可以用来进行流量控制,特别是资源有限的应用场景,比如构建一些对象池,资源池之类的我们也可以创建计数为1的Semaphore,将其作为一种类似互斥锁的机制,这也叫二元信号量,表示两种互斥状态。下面用Semphore实现一个简单的可以控制连接数量的数据库连接池:执行结果在代码中,虽然有5个线程需要获取数据库连接,但是通过Semaphore限流,每次最大允许3个线程获得数据库连接,从而保证了数据库的安全性。Semaphore的方法Semaphore除了上面使用的两个方法:acquire()和release()之外,还提供了许多有用的方法:

    LoveIT 2020-04-13
    Java多线程与高并发
  • 高并发编程之ReentrantLock—源码剖析

    高并发编程之ReentrantLock—源码剖析

    一、从Lock接口说起锁时用来控制多个线程范文共享资源,保证资源安全性的一种重要手段。在Lock接口出现以前,Java陈旭是靠synchorinzed关键字实现锁功能点的,在jdk1.5之前使用synchroinzed绝对是一个重量级的操作,因为synchroinzed是依赖对象锁实现的,而对象锁(ObjectMonitor)又依赖底层操作系统的mutexlock指令,这个指令需要操作系统在用户态和内核态不断的切换,这是一个很消耗CPU资源的操作。好的一点是Java在jdk1.5提供了J.U.C并发包,其中新增的Lock接口具有和synchorinzed同样的锁功能,只是在使用的时候需要我们显示的获取和释放锁。就像下面这样:Lock接口的APILock是一个接口,它定义了所的获取和释放的基本操作,Lock接口定义方法有如下几个:Lock接口中的这些接口方法在ReentrantLock这个类中有具体的实现,接下来我们来首先了解一下ReentrantLock的基本特性,之后我们在深入源码分析一下这把锁的原理。二、ReentrantLock基本特点(1)可重入:和synchronized一样是一个可重入锁(2)可以响应中断:ReenTrantLock提供了一种能够中断等待锁的线程的机制。(3)可以通过构造器构造公平锁还是非公平锁:ReenTrantLock可以指定是公平锁还是非公平锁;synchronized只能是非公平锁。(4)可以在获取锁的时候设置超时时间(5)需要显示的获取和释放锁三、深入源码分析RentrantLock在阅读ReentrantLock的源码之前,我建议你最好是知道CAS、volatile、以及AQS这些基本的概念的,不然你会越读越糊涂。如果你确实对这些知识优点含糊不清或者压根不知道,那么你可以百度先了解一下,或者你也可以在我的博客Java多线程与高并发分类中找到相应的文章学习。好了,我么回到主题。首先我们来看一下ReentranLock的类图:从类图中可以直观的看到,Reentrantlock继承了上面我们说的Lock接口,并且在其内部有三个内部类:Sync、NonfairSync、FairSync。其中Sync继承了AQS,NonfairSync、FairSync继承了Sync,他们分别是非公平锁好公平锁的实现。接下来我们对这三个部分详细分析一下。1、ReentrantLock的构造方法ReentrantLock有两个构造方法,默认无参的构造方法是一个非公平锁,但是我们可以使用另一个有参构造方法构建一个公平锁。下面是两个构造方法的源码:可以看到,默认的无参构造方法他是直接new了一个非公平同步器(即非公平锁),而在有参构造方法中,我们传入true表示构造一个公平锁,反之就是非公平锁。你问我sync变量是啥?这是整个ReentrantLock唯一的一个成员变量,ReentrantLock就是通过聚合了一个同步器的子类完成了对多个线程的访问控制。Sync是一个抽象类,因此在使用的时候一定需要一个具体的实现类,NonfairSync、FairSync就是具体的实现。现在我们大概已经理清了ReentrantLock中各部分之间的关系了,接下来,我们就从非公平锁入手分析一下lock.lock()这条语句的执行流程。下面使我们在程序中调用的lock()方法的源码:2、深入源码分析获取非公平锁原理在publicvoidlock()方法中调用了Sync类的lock()方法,这个方法在NonFairSync中的实现如下:方法的大致逻辑如下:1、首先调用AQS中的compareAndSetState(int,int)方法先尝试将控制同步状态的变量由0变为1,如果操作成功了就表示同步锁获取成功了(这一步就是非公平性产生的原因),之后就将当前线程设置为独占锁的拥有者;如果首次获取尝试失败就会调用acquire()方法排队等待获取锁了,这个方法在AQS中,具体的实现原理请参考深入源码分析AQS实现原理。需要注意的是在acquire(intarg)方法中调用了tryAcquire(intarg)方法,此方法在AQS中没有实现,在NonFiarSync中有实现,但是它最终调用了Sync类中的nonfairTryAcquire(intarg)方法,下面是该方法的源码:方法的逻辑大致如下:1、获取到当前线程以及当前同步队列的锁状态;2、有两个if分支,如果锁状态变量state==0,表示当前同步锁没有被其他线程占用,那就用CAS原子的将state从0改为1,表示获取同步锁成功了,获取成功之后会把当前线程会把自己设置为同步锁的拥有者。3、如果当前线程判断同步锁的状态不为0,那就说明同步锁已经在使用中了,于是当前线程再次判断在使用锁的线程是不是自己,如果是自己,那就再次进去,由于此时这个线程就处于重入的状态了,因此绝对没有其他线程和它争抢state了,因此使用普通的setState(intarg)方法把state++(此时state就表示当前线程重入的次数)并更新即可。NonFairSync获取锁的流程图如下所示:3、深入源码分析获取公平锁原理在前面我说到过,在ReentrantLock的有参构造方法中传入true参数就可以得到一个公平锁,就像下面这样:在publicvoidlock()方法中调用了Sync类的lock()方法,lock()方法直接将每一个线程排队等待获取锁,这也就是公平的原因,每个线程在同步队列中排队人人都有获取到锁的机会。这个方法在FairSync中的实现如下:还是同样的配方,还是同样的套路,我们直接看在FairSync中的tryAcquire()方法的实现即可,上图第二个方法就是在FairSync中对tryAcquire()方法的实现,方法的逻辑大致如下:1、得到当前线程,并且获得同步启动器的同步锁状态2、如果同步器的锁状态为0,说明还没有线程持有该锁,那么就判断一下当前队列是否还没有初始化,如果没有初始化,那就先初始化,初始化完成后在使用CAS把同步器的state从0设置为1,设置成功了就表示获取锁成功了,之后还是把自己设置为当前同步器锁的持有者。3、如果当前线程判断同步锁的状态不为0,那就说明同步锁已经在使用中了,于是当前线程再次判断在使用锁的线程是不是自己,如果是自己,那就再次进去,由于此时这个线程就处于重入的状态了,因此绝对没有其他线程和它争抢state了,因此使用普通的setState(intarg)方法把state++(此时state就表示当前线程重入的次数)并更新即可。其实细心观察我们就会发现,这个方法的实现和前面我们将的nonfairTryAcquire(intarg)方法的实现基本一致。4、深入源码分析锁的释放由于ReentrantLock在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:可以看到,本质释放锁的地方,还是通过AQS实现的。下面是AQS中的release()方法源码:关于这个方法的解析在我的博客深入源码分析AQS实现原理有说明。这里不再细说,他会首先调用tryRelease()方法尝试释放锁,释放锁成功之后再通过unparkSuccessor()方法(本质是通过LockSupport.unpark()方法)唤醒后继结点。其中tryRelease()在Sync中的实现如下:方法的逻辑不难理解:1、没执行一次tryRelease()方法状态变量就会执行一次state--操作2、接着判断一下当前线程是否是持有锁的线程,如果不是就会抛出异常,否则进入下一步3、当锁状态state减为0了就说明把锁释放成功了,此时需要把锁的持有线程在AQS中引用设置为null,好让其他线程可以获得锁,最后锁释放成功会返回true。四、实现自定义同步锁在学习了AQS和Reentrant之后,为了加深对锁的理解以及应用,我们自己来实现一个简单的互斥锁MutexLock。思路:实现我们可以实现Lock接口,然后在我们的锁类中聚合AQS,使用他来管理多个多个线程的同步访问的排序。并且在测试中我们来做一道经典的多线程题目:启动10个线程,第一个线程从1加到10,第二个线程从11加到20...第十个线程从91加到100,最后再把十个线程结果相加。自定义互斥锁测试类启动10个线程,第一个线程从1加到10,第二个线程从11加到20...第十个线程从91加到100,最后再把十个线程结果相加执行结果

    LoveIT 2020-04-12
    Java多线程与高并发
  • 高并发编程之AQS(AbstractQueuedSynchornizer)—源码剖析

    高并发编程之AQS(AbstractQueuedSynchornizer)—源码剖析

    AQS概述AQS是AbstractQueuedSynchorinizer的简写,中文名是队列同步器。AQS是Java实现阻塞式锁和同步工具的基石。它使用一个int类型的成员变量state表示同步状态,通过内置的一个FIFO队列完成线程的排队工作。AQS是一个抽象类,主要是通过继承的方式来使用,它本身没有实现任何的同步接口,仅仅是定义了同步状态的获取以及释放的方法来提供自定义的同步组件。一、AQS的实现分析1、使用一个共享变量state实现互斥AQS使用一个int成员变量state来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。状态信息通过getState(),setState(),compareAndSetState()进行操作。AQS支持两种类型的同步方式:独占式:同一时刻只允许一个线程独占式的访问资源;共享式:同一时刻允许多个共享式的线程访问资源;共享式访问的资源时,其他共享式的访问均被允许,而独占式访问被阻塞;独占式访问资源时,同一时刻的其他访问均被阻塞。这两种模式在AQS分别使用两个变量表示:这样设计方便使用者实现不同类型的同步组件,独占式如ReentrantLock,共享式如Semaphore,CountDownLatch,组合式的如ReentrantReadWriteLock。总之,AQS为使用提供了底层支撑,如何组装实现,使用者可以自由发挥。同步器的设计是基于模板方法模式的,一般的使用方式是这样:(1)使用者继承AbstractQueuedSynchronizer并实现自定的方法。(2)将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。这其实是模板方法模式的一个很经典的应用。继承同步器后必须重写如下方法:2、同步队列的基本数据结构上面说到,AQS在其内部维护了一个队列,可是我还要告诉你AQS内部的等待队列实质是一个双向链表,链表中的每一个节点的构造如下(抛开其他信息,先了解一下他的底层数据结构):同步器依赖内部的同步队列来完成同步状态的管理,当线程获取同步状态失败的时,同步器会将当前线程以及等待信息等构成一个节点(Node)并将其加入到同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点的线程唤醒,使其尝试获取同步状态。其中每一个Node结点在Node这个类中还定义了4种等待状态(waitStatus值):CANCELLED(1):表示当前线程已经被取消(等待超时或者被中断了)SIGNAL(-1):表示后继线程需要被唤醒CONDITION(-2):表示结点线程等待在condition上,当被signal后,会从等待队列转移到同步到队列中PROPAGATE(-3):表示下一次共享式同步状态会被无条件地传播下去0:当一个Node被初始化时waitStatus的默认值3、向同步队列加入结点实现线程排队结点加入到同步队列中以后进入一个自旋的过程,每个结点都在观察,当条件满足后,如果获取到了同步状态(锁)就可以从自旋状态病虫同步队列中移除,否则依旧留在同步队列自旋。同步队列是遵循FIFO的,首节点表示获取同步状态成功的结点,首节点的线程在释放同步状态时,将会唤醒后继结点,而后继结点将会在获取同步状态成功将会在此时将自己设置为头结点。设置首节点是通过获取到同步状态的线程来完成的,有只有一个线程能够获取到同步状态,一次设置头结点的方法不需要使用CAS,他只需要将首节点设置成为原结点的后继结点即可(head->next=this.next;)。4、阻塞和唤醒线程。当线程加入到同步队列之后,通过某种方法实现对线程的阻塞和唤醒。在AQS中使用的是LockSupport。LockSupport.park()可以阻塞当前线程,LockSupport.unpark()可以指定唤醒某个线程。LockSupport底层基于Unsafe类,这个类的提供了一些绕开JVM的更底层功能,功能十分强大。二、AQS源码分析1、独占式同步锁的获取与释放流程分析通过调用同步器的acquire(intarg)方法可以获取同步状态,调用了该方法后的线程不会对中断操作响应。方法的代码如下:方法的主要完成了同步状态获取、结点构造、加入同步队列以及在同步队列中自旋等操作。主要逻辑是:首先调用tryAcquire(intarg)方法,该方法的作用是独占式获取同步状态,底层依赖CAS实现,如果获取成功返回true,否则返回false;需要注意的是,这个方法在AQS内部并没有真正的逻辑,他把这部分交给子类实现。如果获取同步失败,则构造同步结点(独占式的,Node.EXCLUSIVE同一时刻只能有一个线程获取到同步锁)并通过addWaiter(Nodenode)方法将该节点加入到同步队列的尾部;最后调用acquireQueued(Nodenode,intarg)方法使得该线程自旋。进一步分析结点是如何构造以及加入到同步队列中的,下面是addWaiter()方法的源码:首先把当前线程以及独占式参数为参数构造一个新节点node;获取当前尾结点,如果尾结点不为空就把node的前驱结点设置为当前尾结点,之后在使用CAS把当前尾结点的后继结点这是为node,成功之后返回node;如果当前尾结点为空,表示当前同步队列还没有初始化,此时会调用enq(Nodenode)方法进行初始化同步队列。如果pred为null(说明同步队列还没与初始化),或者pred在极端情况下被背的线程修改了。其实就需要看一下enq(Nodenode)方法的实现了。enq(Nodenode)方法的源码如下:我们可以看到,在enq(Nodenode)方法中就是一个死循环配合CAS在不断的重试(自旋),直到CAS设置成功了才会返回,方法的主要逻辑如下:获取当前同步队列的尾结点,判断如果是null(说明还没有初始化同步队列),就执行初始化,通过CAS为同步队列设置头结点(第一个结点),此过程循环配合CAS直到执行成功为止;请注意,初始化的头结点并不是当前线程节点,而是调用了无参构造函数的节点。同步器初始化成功在配合CAS以及循环设置队尾,也是直到成功为止。在不断重试的过程中,只有通过CAS把结点设置成为了尾结点之后,当前线程才能从方法返回,否则就不断的重试。通过不断的重试,以及CAS机制,将并发的添加节点的操作串行化,从而保证了线程安全。结点加入到同步队列后,线程就会进入到自旋的过程,每个线程都在观察,当条件满足,获取到了同步状态,就可以从自旋过程退出,否则依旧自旋。下面是acquireQueued(Nodenode,intarg)方法的源码:在acquireQueued()方法中,当前线程不断的尝试获取同步状态,从代码中我们也看到了。只有当前驱是head的结点才可以尝试获取锁,这么做的理由有以下两个:(1)这样做可以维护队列的FIFO特性;(2)头结点是成功获取到同步状态的结点,二头结点的线程释放了同步状态后,将会唤醒其后继结点,后继结点的线程被唤醒或需要检查自己的前驱结点是否是头结点。而acquireQueued()方法的逻辑主要如下:首先获取当前节点的前驱结点,如果前驱节点是head,那么当前结点可以尝试获取锁;获取锁成功后就不需要再进行同步操作了,获取锁成功的线程作为新的head节点;如果获取锁失败,则根据节点的waitStatus决定是否需要挂起线程如果在执行过程中抛出了异常则取消锁的获取并把当前结点出队独占锁的获取流程(acquire(intarg)方法)的执行流程可以用下图说明:当一个线程获得同步状态并执行完相应的逻辑之后,就需要释放同步同步状态,是的后续接待也可以获得同步状态。通过调用同步器的relaese(intarg)方法可以释放同步锁状态,该方法在释放了昂前线程的同步状态之后会唤醒其后继结点。下面是该方法的源码:方法会首先调用tryRelaese(intargs)释放同步状态,这个方法在AQS没有实现,需要子类实现具体的逻辑。tryRelaese(intargs)返回true之后调用unparkSuccessor(h)方法,在这个方法内会使用LockSupport.unpark()方法唤醒当前结点的后继结点。关于LockSupport的原理可以参考我的博客LockSupport深入源码剖析分析到这,适当的做个总结在获取同步锁状态时,同步器维护了一个同步队列,获取同步状态失败的线程都会被包装成一个Node结点加入到同步器的尾部,在添加的时候是以CAS的方式设置尾结点的,并且添加进队列中的结点自旋;移除同步队列的条件是,当前结点的前驱结点是head结点并且成功获取到同步状态。在释放同步锁的时,同步器调用tryRelease(intarg)方法释放同步状态,并且调用LockSupport.unpark(Threadt)方法唤醒头结点的后续结点。2、共享式同步状态的获取与释放流程分析通过调用同步器的**acquireSahared(intarg)**方法可以共享式的获取同步状态,源码如下:在acquireSahared(intarg)方法中,同步器调用tryAcquire(intarg)方法尝试获取同步状态,这个方法同样地AQS没有没有具体实现,需要子类来实现。这个tryAcquire(intarg)方法返回一个int类型的值,当返回值大于等于0时,表示获取到了同步状态;否则就会调用doAcquireShared(intarg)以自旋的方式获取同步状态。doAcquire(intarg)方法源码如下:在方法doAcquireShared(intarg)自旋的过程中,如果当前结点的前驱结点为head时,还是会调用tryAcquire(intarg)尝试获取同步状态,如果返回值大于等于0,表示获取同步状态成功,于是就可以退出自旋了。同样地,如果在自选的过程中发生了异常就会退出自旋,并且把当前结点从同步器中移除。当共享式的同步状态的线程需要释放同步状态时同步器可以调用releaseSgared(intarg)方法释放同步状态。在方法内部调用了tryReleaseShared(intarg)这个方法也是需要子类去实现的,当到方法返回true时表明释放同步状态成功,如果首次调用释放同步状态失败就会调用doReleaseShard(intarg)方法以自旋的方式不断重试释放同步状态。同时由于这是共享式的,为了保证多个线程同时访问的并发性,方法内部采用了CAS。最终方法会唤醒头头结点的下一个结点,使用方法任然是unparkSuccessor(h),这个方法在上面分析过,他其实是调用了LockSupport.unpark()方法。

    LoveIT 2020-04-11
    Java多线程与高并发
  • 高并发编程之线程池ThreadPoolExecutor详解

    高并发编程之线程池ThreadPoolExecutor详解

    如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?在Java中可以通过线程池来达到这样的效果。今天我们就来详细讲解一下Java的线程池,首先我们从最核心的ThreadPoolExecutor类中的方法讲起,然后再讲述它的实现原理,接着给出了它的使用示例,最后讨论了一下如何合理配置线程池的大小。说了这么多首先我们需要明确我们为什么需要使用线程池?(1)降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗(2)提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。(3)提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。因此为了合理的使用线程池我们需要深入的了解一下其中的原理,首先我们来看一下Java中线程池的继承体系,如下图所示:1、最顶层的Executor是一个接口,它里面只有一个方法:execute(),用于向线程池提交任务。2、Executor下有一个重要子接口ExecutorService,其中定义了线程池的具体行为1,execute(Runnablecommand):履行Ruannable类型的任务,2,submit(task):可用来提交Callable或Runnable任务,并返回代表此任务的Future对象3,shutdown():有序的完成已提交的任务,但是不再接收新任务,4,shutdownNow():停止所有正在执行的任务,暂停正在等待的任务的处理,并返回正在等待执行的任务的列表。5,isTerminated():测试是否所有任务都执行完毕了。6,isShutdown():测试是否该线程池是否被关闭。3、ThreadPoolExecutor是Java中线程池的核心实现类,用来执行被提交的任务。接下来我们就以ThreadPoolExecutor为中心来探究一下Java中线程池的原理。一、ThreadPoolExecutor构造方法ThreadPoolExecutor有很4个构造方法,这里我用参数最全的一个来说明一下各个参数的含义:1、corePoolSize:线程池中核心线程个数2、maximunPoolSize:线程池最大允许的线程个数(这里有一个救急线程的概念,就是非核心线程,它的数量是maximunPoolSize-corePoolSize)3、keepAliveTime:这个参数是给非核心线程设定的,他表示非核心线程可以空闲时间,超过这个时间就会被回收4、unit:空闲时间的单位5、workQueue:任务队列,它是一个阻塞队列,用于存放等待执行的任务。Java中提供的阻塞队列有以下几个:(1)ArrayBlockingQueue:基于数组的有界队列,遵循先进先出(2)LinkedBlockingQueue:基于链表的有界队列,遵循先进先出,吞吐量高于ArrayBlockingQueue(3)SynchronousQueue:一个不存储元素的阻塞队列,每一个插入操作必须要等到另一个线程调用移除操作才可以执行,否则会一直阻塞,吞吐量比LinkedBlockingQueue高(4)PriorityBlockingQueue:一个具有优先级的阻塞队列6、threadFactory:线程工厂,用于创建线程以及可以给线程起一个有意义的名字7、RejectedExecutionHandler:当任务队列和线程池都处于满负荷运行时,新提交的任务应该如何处理的策略,称为饱和策略。Java中提供的策略有以下4种:(1)ThreadPoolExecutor.AbortPolicy:直接抛出异常,这是默认的策略(2)ThreadPoolExecutor.CallerRunsPolicy:让调用者来执行该任务(3)ThreadPoolExecutor.DisCardPolicy:不做任何处理,直接把任务丢掉,也不抛出任何异常(4)ThreadPoolExecutor.DisCardOldestPolicy:丢弃任务队列头部的任务,然后尝试执行当前任务当然,我们也可以根据我们的业务场景通过实现RejectExeptionPolicy接口实现自定义策略。二、线程池的重要属性1、线程池状态以及有效线程数量属性—ctlctl是一个控制线程池状态以及线程池中有效线程数量的int类型字段,在这一个字段中包含了两部分信息:线程池的运行状态(runState)和线程池内有效线程的数量(workerCount),这里可以看到,使用了int类型来保存,高3位保存runState,低29位保存workerCount。这么做是为了保证修改线程池状态以及线程池中有效线程数量的操作是一个原子操作的前提前可以借助无锁机制提高效率,如果用两个变量分别保存的话就要通过加锁来保证原子性了。COUNT_BITS就是29,CAPACITY就是1左移29位减1(29个1),这个常量表示workerCount的上限值,大约是5亿。2、ctl有关的方法:3、线程池的5个状态RUNNING:线程池处于运行状态,可以正常处理并接受新任务,线程池的初始状态就是RUNNING;SHUTDOWN:当调用shutdown()方法后会由RUNNING转变为SHUTDOWN状态,此时线程池可以正常处理任务但是无法再接收新任务;STOP:代用shutdownNow()方法后,线程会转变为STOP状态,此时线程不再处理已提交的任务并且无法在接受新任务,并且还会中断处理中的任务;TIDYING:当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。TERMINATED:线程池彻底终止,就变成TERMINATED状态。线程池处在TIDYING状态时,执行完terminated()方法之后,就会由TIDYING->TERMINATED。4、线程池构建有关的属性三、线程池的基本实现原理ThreadPoolExecutor中有两个方法可以用于向线程池提交任务,分别是execute()和submit()两个方法。execute()方法用于提交不需要返回值的任务,submit()用于提交需要返回值的任务,返回值被封装在Future接口中,可以通过提供的get()方法获取返回值。下面通过源码我们来探究一下。1、execute()方法总结一下execute()方法运行的主要流程如下图所示:从图中可以看出,当提交一个新任务当线程池以后,exectue()的处理流程大致如下:1、首先判断核心线程池中的线程是否都有处于工作状态,如果没有就创建新的线程执行任务,否则进入下一步2、判断任务队列是否已经满了,如果没有满就班新任务放入阻塞队列中等待被执行;否则进入下一步3、判断线程池的线程是否都处于工作状态,如果没有就创建新的线程执行任务,否则把任务交给饱和策略处理。2、addWorker()方法addWorker主要的功能就是创建一个新线程并执行提交的任务。firstTask参数表示该线程创建后执行的第一个任务;core参数如果为true表示限制线程池中的线程数量应该小于corePoolSize,为false表示限制线程池中的线程数量应该小于maximumPoolSize3、内部类WorkerWorker中的重要属性以及构造方法线程池中的每一个线程被封装成了一个Worker对象,ThreadPoolExecutor维护的其实就是一组Worker对象。Worker类继承了AQS,并实现了Runnable接口,注意其中的firstTask和thread属性:firstTask用来保存传入的任务;thread是在调用构造方法时保存通过ThreadFactory创建的线程,是用来处理任务的线程。在调用构造方法时,需要把任务传入,这里通过getThreadFactory().newThread(this);来新建一个线程,newThread方法传入的参数是this,因为Worker本身继承了Runnable接口,也就是一个线程,所以一个Worker对象在启动的时候会调用Worker类中的run方法。Worker继承了AQS,使用AQS来实现独占锁的功能。为什么不使用ReentrantLock来实现呢?可以看到tryAcquire方法,它是不允许重入的,而ReentrantLock是允许重入的:(1)lock方法一旦获取了独占锁,表示当前线程正在执行任务中;(2)如果正在执行任务,则不应该中断线程;(3)如果该线程现在不是独占锁的状态,也就是空闲的状态,说明它没有在处理任务,这时可以对该线程进行中断;(4)线程池在执行shutdown方法或tryTerminate方法时会调用interruptIdleWorkers方法来中断空闲的线程,interruptIdleWorkers方法会使用tryLock方法来判断线程池中的线程是否是空闲状态;(5)之所以设置为不可重入,是因为我们不希望任务在调用像setCorePoolSize这样的线程池控制方法时重新获取锁。如果使用ReentrantLock,它是可重入的,这样如果在任务中调用了如setCorePoolSize这类线程池控制的方法,会中断正在运行的线程。所以,Worker继承自AQS的作用是判断线程是否空闲以及是否可以被中断。此外,在构造方法中执行了setState(-1);,把state变量设置为-1,为什么这么做呢?是因为AQS中默认的state是0,如果刚创建了一个Worker对象,还没有执行任务时,这时就不应该被中断,可以看一下Worker中的tryAcquire方法:tryAcquire方法是根据state是否是0来判断的,所以将state设置为-1是为了禁止在执行任务前对线程进行中断。正因为如此,在runWorker方法中会先调用Worker对象的unlock方法将state设置为0。4、runWorker()方法在Worker类中的run()方法调用了runWorker()方法来执行任务,runWorker()方法的代码如下:总结runWorker()方法的执行过程如下:while循环不断地通过getTask()方法获取任务getTask()方法从阻塞队列中取任务(下面会分析到)如果线程池正在停止,那么要保证当前线程是中断状态,否则要保证当前线程不是中断状态;调用task.run()执行任务;当通过getTask()获取任务为null时跳出循环,执行processWorkerExit()方法,对线程做一些善后处理;runWorker方法执行完毕,也代表着Worker中的run方法执行完毕,销毁线程。5、getTask()方法getTask()方法是用来从阻塞队列中获取任务的方法,源码如下:6、processWorkerExit()方法在runWorker()方法中当通过getTask()获取任务是返回null时,一个线程的生命就到达终点了,此时他会执行processWorkerExit()方法把自己从线程池维护的HashSet(workers)中移除了。至此,processWorkerExit执行完之后,工作线程被销毁,以上就是整个工作线程的生命周期,从execute()方法开始,Worker使用ThreadFactory创建新的工作线程,runWorker()通过getTask()获取任务,然后执行任务,如果getTask()返回null,进入processWorkerExit()方法,整个线程结束,如图所示:四、使用线程池的正确姿势1、向线程池提交任务ThreadPoolExecutor中有两个方法可以用于向线程池提交任务,分别是execute()和submit()两个方法。execute():用于提交不需要返回值的任务;submit():用于提交需要返回值的任务,返回值被封装在Future接口中,可以通过提供的get()方法获取返回值。我们在使用线程池的时候最好不要直接使用工具类Executors中提供的Executors.newXXXThreadPool()快捷方法创建线程池,因为这种方式会使用无界的任务队列,为避免OOM,我们应该使用ThreadPoolExecutor的构造方法手动指定队列的最大长度:​在使用线程池的时候一定要根据任务的特性合理的配置线程池才能最大限度的发挥线程池带来的好处。对于CPU密集型任务应该配置尽可能少的线程,通常配置线程个数略多与CPU个数即可;对于IO密集型任务,每个线程在IO阻塞的时间远远大于其运行的时间,此时可以配置尽可能多的CPU;当然,这只是一个参考值,具体的设置还需要根据实际情况进行调整,比如可以先将线程池大小设置为参考值,再观察任务运行情况和系统负载、资源利用率来进行适当调整。2、线程池的初始化默认情况下,创建线程池之后,线程池中是没有线程的,需要提交任务之后才会创建线程。  在实际中如果需要线程池创建之后立即创建线程,可以通过以下两个方法办到:prestartCoreThread():初始化一个核心线程;prestartAllCoreThreads():初始化所有核心线程下面是这2个方法的实现:3、关闭线程池 ThreadPoolExecutor提供了两个方法,用于线程池的关闭,分别是shutdown()和shutdownNow(),其中:shutdown():不会立即终止线程池,而是要等所有任务缓存队列中的任务都执行完后才终止,但再也不会接受新的任务,,当执行这个方法后线程池就会从RUNNING==>SHUTDOWNshutdownNow():立即终止线程池,并尝试打断正在执行的任务,并且清空任务缓存队列,返回尚未执行的任务4、动态调整线程池容量ThreadPoolExecutor提供了动态调整线程池容量大小的方法:setCorePoolSize()和setMaximumPoolSize(),setCorePoolSize():设置核心池大小setMaximumPoolSize():设置线程池最大能创建的线程数目大小  当上述参数从小变大时,ThreadPoolExecutor进行线程赋值,还可能立即创建新的线程来执行任务。五、Excutors工具类中的4类基于ThreadPoolExecutor线程池简单分析1、newFixedThreadPool()特点:是一个固定大小的线程池,核心线程corePoolSize=maxinumnPoolSize,因此无需超时时间阻塞队列使用的是LinkedBlockingQueue,没有显示大小,可以看成一个是一个无界阻塞队列,当队列一直添加就可能会导致OOMFixedThreadPool适用于我了满足资源管理的需求,而需要限制当前线程数量的应用场景,是哟适用于负载比较重的服务器。2、newCacheedThreadPool()特点:核心线程数为0,最大线程数是Integer.MAX_VALUE。阻塞队列采用了SynchronousQueue,使用的SynchronousQueue,也就是说来了任务就创建线程运行,当线程空闲超过60秒,就销毁线程。适用于执行大量的的短期异步任务或负载较轻的服务器3、newSingleThreadPool()特点:线程池只有一个线程,并且也是使用LinkedBlockingQueue适用于需要保证任务串行执行的场景。4、newScheduledThreadPool()特点:最大线程数为Integer.MAX_VALUE阻塞队列是DelayedWorkQueuekeepAliveTime为0scheduleAtFixedRate():按某种速率周期执行scheduleWithFixedDelay():在某个延迟后执行总结Executors为我们提供了构造线程池的便捷方法,对于服务器程序我们应该杜绝使用这些便捷方法,而是直接使用线程池ThreadPoolExecutor的构造方法,避免无界队列可能导致的OOM以及线程个数限制不当导致的线程数耗尽等问题。ExecutorCompletionService提供了等待所有任务执行结束的有效方式,如果要设置等待的超时时间,则可以通过CountDownLatch完成。

    LoveIT 2020-04-09
    Java多线程与高并发
  • 深入理解Java魔法类:Unsafe应用解析

    深入理解Java魔法类:Unsafe应用解析

    前言​Unsafe是位于sun.misc包下的一个类,主要为我们提供了一些用于执行级别低,不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这种机制仅供java核心类库使用,而不应该被普通用户使用。其实例一般情况是获取不到的,源码中的设计是采用单例模式,不是启动类加载器加载初始化就会抛SecurityException异常。​这个类的提供了一些绕开JVM的更底层功能,基于它的实现可以提高效率。但是,它是一把双刃剑:正如它的名字所预示的那样,它是不安全的,它所分配的内存需要手动free(不被GC回收)。如果对Unsafe类理解的不够透彻,就进行使用的话,就等于给自己挖了无形之坑,最为致命。​由于sun并没有将其开源,也没给出官方的Document。但是,为了更好地了解java的生态体系,我们应该去学习它,去了解它,不求深入到底层的C/C++代码,但求能了解它的基本功能。一、获取Unsafe实例如下Unsafe源码所示,Unsafe类为一单例实现,提供静态方法getUnsafe获取Unsafe实例,当且仅当调用getUnsafe方法的类为引导类加载器所加载时才合法,否则抛出SecurityException异常。VM类中的isSystemDomainLoader()方法它在返回实例之前会获取当前调用者的类加载器,如果不是启动类加载器就会抛出SecurityException异常。获取Unsafe实例的正确姿势那如若想使用这个类,该如何获取其实例?有如下两个可行方案。其一,从getUnsafe方法的使用限制条件出发,通过Java命令行命令-Xbootclasspath/a把调用Unsafe相关方法的类A所在jar包路径追加到默认的bootstrap路径中,使得A被引导类加载器加载,从而通过Unsafe.getUnsafe方法安全的获取Unsafe实例。其二,通过反射获取单例对象theUnsafe。查看源码,我们发现Unsafe内部有一个属性叫theUnsafe,我们直接通过反射拿到它即可。二、功能介绍如上图所示,Unsafe提供的API大致可分为内存操作、CAS、Class相关、对象操作、线程调度、系统信息获取、内存屏障、数组操作等几类,下面将对其相关方法和应用场景进行详细介绍。内存操作这部分主要包含堆外内存的分配、拷贝、释放、给定地址值操作等方法。通常,我们在Java中创建的对象都处于堆内内存(heap)中,堆内内存是由JVM所管控的Java进程内存,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理堆内存。与之相对的是堆外内存,存在于JVM管控之外的内存区域,Java中对堆外内存的操作,依赖于Unsafe提供的操作堆外内存的native方法。DirectByteBuffer是Java用于实现堆外内存的一个重要类,通常用在通信过程中做缓冲池,如在Netty、MINA等NIO框架中应用广泛。DirectByteBuffer对于堆外内存的创建、使用、销毁等逻辑均由Unsafe提供的堆外内存API来实现。下图为DirectByteBuffer构造函数,创建DirectByteBuffer的时候,通过Unsafe.allocateMemory分配内存、Unsafe.setMemory进行内存初始化,而后构建Cleaner对象用于跟踪DirectByteBuffer对象的垃圾回收,以实现当DirectByteBuffer被垃圾回收时,分配的堆外内存一起被释放。CAS相关操作CAS即CompareAndSweap或CompareAndSet的英文缩写,中文含义是比较并替换,是实现并发算法时常用到的一种技术。CAS操作需要三个操作数:内存地址、目标预期值、新值。执行CAS替换的时候,会将内存地址中的值和目标预期值比较,当前仅当这两个值相等的时候处理器会自动将该位置值更新为新值,否则,处理器不做任何操作。CAS是一条CPU的原子指令(cmpxchg指令),不会造成所谓的数据不一致问题,Unsafe提供的CAS方法(如compareAndSwapXXX)底层实现即为CPU指令cmpxchg。具体有以下几个方法:线程调度这部分,包括线程挂起、恢复、锁机制等方法。这一部分的典型应用就是Java锁和同步器框架的核心类AbstractQueuedSynchronizer,通过调用LockSupport.park()和LockSupport.unpark()实现线程的阻塞和唤醒的,而LockSupport的park、unpark方法实际是调用Unsafe的park、unpark方式来实现。Class相关操作此部分主要提供Class和它的静态字段的操作相关方法,包含静态字段内存定位、定义类、定义匿名类、检验&确保初始化等。对象操作此部分主要包含对象成员属性相关操作及非常规的对象实例化方式等相关方法。典型应用非常规的实例化对象实例:使用Unsafe中提供allocateInstance方法,仅通过Class对象就可以创建此类的实例对象,而且不需要调用其构造函数、初始化代码、JVM安全检查等。它抑制修饰符检测,也就是即使构造器是private修饰的也能通过此方法实例化,只需提类对象即可创建相应的对象。由于这种特性,allocateInstance在java.lang.invoke、Objenesis(提供绕过类构造器的对象生成方式)、Gson(反序列化时用到)中都有相应的应用。数组相关这部分主要介绍与数据操作相关的arrayBaseOffset与arrayIndexScale这两个方法,两者配合起来使用,即可定位数组中每个元素在内存中的位置。内存屏障在Java8中引入,用于定义内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。系统相关这部分包含两个获取系统相关信息的方法。三、Unsafe的使用示例1、使用Unsafe实例化一个类有一个User类,常规的获取这个类的对象的实例的方法有:使用new关键字,使用Java反射机制、反序列化等操作然而通过Unsafe的allocateInstance()方法,我们只要这个类的Class对象就可以实例化对象,而且这种实例化方式是不用调用该类的构造方法的,单例模式瞬间瑟瑟发抖[手动狗头]执行结果:从打印的结果来看,字段全是默认初始值,没有执行构造方法初始化。其实allocateInstance()方法只会给对象分配内存,并不会调用构造方法。你问我这个用啥用?这个对于单例模式简直是无解的开挂行为,因为他实例化对象不需要经过构造器。2、修改私有字段的值使用Unsafe的putXXX()方法,我们可以修改任意私有字段的值。这里我们可以配合第1种用法来给一个对象分配内存以及初始化。(当然我们也可以通过反射直接修改。)3、抛出checked异常我们知道如果代码抛出了checked异常,要不就使用try...catch捕获它,要不就在方法签名上定义这个异常,但是,通过Unsafe我们可以抛出一个checked异常,同时却不用捕获或在方法签名上定义它。4、在堆外分配内存使用java的new会在堆中为对象分配内存,并且对象的生命周期内,会被JVMGC管理。Unsafe分配的内存,不受Integer.MAX_VALUE的限制,并且分配在非堆内存,使用它时,需要非常谨慎:忘记手动回收时,会产生内存泄露;非法的地址访问时,会导致JVM崩溃。在需要分配大的连续区域、实时编程(不能容忍JVM延迟)时,可以使用它。java.nio使用这一技术。Spark中的Netty也使用了这个技术。假设我们要在堆外创建一个巨大的byte数组,我们可以使用allocateMemory()方法来实现:OffHeapArray执行结果:你问我这个有啥用?Java的数组最大容量受常量Integer.MAX_VALUE的限制,如果我们用直接申请内存的方式去创建数组,那么数组大小只会收到堆的大小的限制。当时使用这个方式理论上可以创建任意大小的数组。5、CAS操作J.U.C底层大量使用了CAS,在AQS、ConcurrentHashmap、ForkJoinPool、FutureTask、StampedLock等都有大量的应用。它们的底层是调用的Unsafe的CompareAndSwapXXX()方法。这种方式广泛运用于无锁算法,与java中标准的悲观锁机制相比,它可以利用CAS处理器指令提供极大的加速。6、park/unpark在LockSupport类中有两个静态方法park()和unpark(),他们的底层调用的就是Unsafe类中对应的park()/unpark()本地方法,当一个线程正在等待某个操作时,JVM调用Unsafe的park()方法来阻塞此线程。当阻塞中的线程需要再次运行时,JVM调用Unsafe的unpark()方法来唤醒此线程。具体的分析请参考我的另一篇文章LockSupport深入源码剖析。7、计算对象的内存大小基本的思路如下:(1)通过反射获得一个类的Field(2)通过Unsafe的objectFieldOffset()获得每个Field的offSet(3)对Field按照offset排序,取得最大的offset,然后加上这个field的长度,再加上Padding对齐Student类,有一个String类型的引用4字节,int类型4字节,然后MarkWord8字节,类型指针4字节,这总共8+4+4+4=20字节,然后需要对齐填充到最近的8字节整数倍,应该是24字节。执行结果:可以看到执行结果符合我们的预期值。

    LoveIT 2020-04-06
    Java多线程与高并发
  • 高并发编程之高性能原子类LongAdder—源码剖析

    高并发编程之高性能原子类LongAdder—源码剖析

    高性能原子类是java8中增加的原子类,它们使用分段的思想(Cell[]),把不同的线程hash到不同的段上去更新,最后再把这些段的值相加得到最终的值,相对Atomic类这些类运行性能更高,这些类主要有:(1)Striped64:下面四个类的父类。(2)LongAccumulator:long类型的聚合器,需要传入一个long类型的二元操作,可以用来计算各种聚合操作,包括加减乘除模。(3)LongAdder:long类型的累加器,LongAccumulator的特例,只能用来计算加法,且从0开始计算。(4)DoubleAccumulator:double类型的聚合器,需要传入一个double类型的二元操作,可以用来计算各种聚合操作,包括加减乘除模。(5)DoubleAdder:double类型的累加器,DoubleAccumulator的特例,只能用来计算加法,且从0开始计算。这里我以LongAdder为例来探究一下由大神DougLea操刀编写的作品之精妙!!!一、LongAdder中几个关键的属性LongAdder继承自Striped64抽象类,Striped64中定义了Cell内部类和各重要属性。LongAdder的原理是,在最初没有竞争的时候使用CAS更新base值就可以了,当有多线程有过竞争时通过分段的思想,让不同的线程更新不同的段,最后把这些段相加就得到了完整的LongAdder存储的值。注意!最初没有竞争是指一开始没有没有线程之间的竞争,但也有可能是多线程在操作,只是多个线程没有同时更新base的值。有过竞争是指只要出现过竞争不管后面有没有竞争都使用cells更新值,规则是不同的线程hash到不同的cell上去更新,减少竞争。二、缓存行伪共享与解决方法1、CPU缓存架构CPU是计算机的心脏,所有运算和程序最终都要由它来执行。主内存(RAM)是运行时数据存放的地方,CPU和主内存之间有好几级缓存(一般是3级缓存L1、L2、L3),因为即使直接访问主内存也是非常慢的,下面是一个CPU访问缓存和访问内存的速度比较:从CPU到大约需要的时总周期寄存器1L1(一级缓存)3~4L2(二级缓存)10~20L3(三级缓存)40~45内存120~240由于内存和CPU的速度差距还是比较大的,为了提高效率,因此需要在CPU和内存之间加缓存预读取数据来提升效率。2、缓存行和伪共享问题​缓存是由缓存行组成的,通常是64字节(常用处理器的缓存行是64字节的,比较旧的处理器缓存行是32字节),并且它有效地引用主内存中的一块地址。一个Java的long类型是8字节,因此在一个缓存行中可以存8个long类型的变量。当缓存行中的数据被一个CPU修改后,为了保证缓存一致性,其他CPU相同缓存行中的数据就都必须失效。表面上看这套机制似乎完美,但是请看这么一个场景:主内存中有两个相邻的值a、b都被加载进了不同核心的缓存中,不出意外的话这两个变量在同一个缓存行里,带来的问题就是比如当核心1对a的值做了修改,那么为了保证缓存一致性,其他核心中缓存了a的缓存行都必须失效,问题就在于失效a的缓存的同时,也会把b失效,反之亦然,虽然保证了缓存的一致性,但是却带来了效率上的降低。这个问题在LongAdder中是存在的在LongAdder中Cell是数组的形式,数组我们都知道空间是连续分配的,一个Cell对象的内存占用在64位系统中是24字节(无论有没有开启指针压缩)。因此一个缓存行可以同时最多放下两个Cell对象,这样在同一个缓存行中的两个Cell对象就会出现一个修改后导致另一个失效的问题。在jdk1.8中使用@sun.misc.Contended注解解决了这个问题,它的原理是在使用此注解的对象或字段前后加上128字节的padding,使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突,从而将这个不同的对象预存到不同的缓存行中。下面是在Striped64中内部类Cell,在他头上就是使用注解@sun.misc.Contended来防止防止缓存行伪共享的。三、核心方法源码解析LongAdder是一个专门做累加的高性能原子类,他只能做加减运算并且初始值是0,这一点从他的构造方法可以看出。下面是LongAdder做类似i++操作的示例:从increment()方法开始我们分析一下具体的执行原理。increment()是对add(long)方法的再次封装,核心逻辑还得看add(long)方法。1、add(long)源码解析add(longx)方法是LongAdder的主要方法,使用它可以使LongAdder中存储的值增加x,x可为正可为负。add()方法的执行流程图:2、longAccumulate()源码解析add()方法中有三种情况会调用longAccumulate()方法,分别是当CAS更新base失败时、当前线程在cells中没有Cell时、当前线程CAS更新cell失败需要扩容时。因此对于longAccumulate()方法的解读我也分为三个部分,下面是方法的总览:累加数组cells还未初始化时执行流程图累加数组cells存在但是当前线程的累加槽位为空以及累加数组存在并且当前线程的累加槽位存在的情况第一种情况:累加数组cells存在但是当前线程的累加槽位为空的执行流程图第二种情况:累加数组cells存在并且当前线程的累加槽位存在时对流程图3、sum()源码解析sum()方法是获取LongAdder中真正存储的值的大小得到方法,通过把base和cells中所有槽位的值相加得到。可以看到sum()方法是把base和所有槽位的值相加得到,那么,这里有一个问题,如果前面已经累加到sum上的Cell的value有修改,不是就没法计算到了么?答案确实如此,所以LongAdder可以说不是强一致性的,它是最终一致性的。总结(1)LongAdder通过long类型的base和Cell数组来存储值(2)不同的线程会hash到不同的cells累加数组的槽位上更新,减少了竞争(3)LongAdder的性能非常高,最终会达到一种无竞争的状态;(4)LongAdder消除缓存行伪共享是通过sun.misc.Contended注解实现的,它的原理就是在使用了这个注解对对象或字段前后都会加上128字节的padding(空白,不会对原数据产生影响),使用2倍于大多数硬件缓存行的大小来避免相邻扇区预取导致的伪共享冲突。(5)cells数组的最大是等于CPU核心数或是小于CPU核心数的最大2的幂,即cells数组的大小不可能大于CPU核心数。

    LoveIT 2020-04-06
    Java多线程与高并发
  • 高并发编程之原子类AtomicStampedReference—源码剖析

    高并发编程之原子类AtomicStampedReference—源码剖析

    AtomicStampedReference是Java提出的一个原子类,它可以解决ABA问题。与这个类有同样作用的类是AtomicMarkableReference,他们都可以解决CAS的ABA问题。一、什么是ABA问题?ABA的危害?ABA问题发生在多线程环境下,说的是当线程A对一个变量使用CAS修改的时候,他两次读到内存地址V中的值一样,然后改线程就简单的认为变量没有发生过修改。然而,同时可能存在另一个线程在这两次读取之间把这个内存地址的值从A修改成了B又修改回了A,这时还简单地认为“没有修改过”显然是错误的。如上图所示,在线程1两次读取到变量的值都是A,但是在这中间线程2对这个变量做了修改,但是对于线程1来说他却感受不到。ABA问题在有些场景中不会产生问题,但是在有些场景中是十分要命的,比如下面这个例子:假设有一个遵循CAS原理的提款机,小明有100元存款,要用这个提款机来取50元。由于提款机硬件出了点小问题,小明的提款操作被同时提交两次,开启了两个线程(线程1,2),两个线程都是获取当前值100元,要更新成50元。正常情况下,应该一个线程更新成功,另一个线程再更新就会失败,小明的存款只被扣一次。线程1首先执行成功,把余额从100改成50。线程2因为某种原因阻塞了。这时候,小明的妈妈刚好给小明汇款50元(线程3)。线程2仍然是阻塞状态,线程3执行成功,把余额从50改成100。线程2恢复运行,由于阻塞之前已经获得了“当前值”100,并且经过compare检测,此时存款实际值也是100,所以“成功”把变量值100更新成了50。这是不合理的。因此当遇到这样的场景时我们就需要预防CAS带来的ABA问题,Java中提供了AtomicStampedReference和AtommicMarkableReference这两个类来解决ABA问题。他们两个实现的手段大致相同,只是前者是通过使用一个int类型的版本号来控制修改的,只有当预期值和内存地址V中的值相等并且版本号一致时才允许修改,而后者是通过一个boolean类型的变量来说明变量是否被修改过,下面我就以AtomicStampedReference深入源码分析他的机制。二、AtomicStampedReference源码分析1、内部类Pair是AtomicStampedReference的内部类,它主要的作用就是把元素和版本号绑定起来。2、属性在AtomicStampedReference中有一个volatile修饰的成员变量Pair类型的pair。pair中保存了reference(元素引用)和版本号(stamp)并且使用Unsafe获取到其偏移量,存储到pairOffset变量中,这个变量在调用Unsafe类中的方法是有用。3、构造方法AtomicStampedReference类只有一个构造方法,第一个参数就是元素的引用,第二个是初始版本号。使用方法如下:4、compareAndSet()方法expectedReference:表示预期引用newReference:新的引用expectedStamp:预期版本号newStamp:更新后的版本号整个方法的逻辑是:先获取到当前的pair对象(也就是当前的旧引用值和版本号),之后比较如果当前pair中的引用值和预期引用值相等并且当前版本和预期版本相等的时候,如果新的引用和旧的引用不相等并且者新的版本号和旧的版本号不相等时才会调用casPair()使用CAS的方式修改pair中的值,反之有一处不符合就不会发生替换。下面是casPair()的实现细节:可以看到,底层最终还是调用了Unsafe类的另一个CAS方法compareAndSwapObject总结一下compareAndSet()方法的逻辑就是:(1)如果元素值和版本号都没有变化,并且和新的也相同,返回true;(2)如果元素值和版本号都没有变化,并且和新的不完全相同,就构造一个新的Pair对象并执行CAS更新pair。可以看到,java中的实现手段:首先,使用版本号控制;其次,不重复使用节点(Pair)的引用,每次都新建一个新的Pair来作为CAS比较的对象,而不是复用旧的;最后,外部传入元素值及版本号,而不是节点(Pair)的引用。

    LoveIT 2020-04-06
    Java多线程与高并发
  • 高并发编程之原子类AtomicInteger—源码剖析

    高并发编程之原子类AtomicInteger—源码剖析

    AtomicInteger是java并发包下面提供的原子类,主要操作的是int类型的数值,通过调用底层Unsafe的CAS等方法实现原子操作。源码分析一、主要属性(1)unsafe:Unsafe类的实例(2)value:使用int类型的value存储值,且使用volatile修饰,volatile主要是保证可见性,即一个线程修改对另一个线程立即可见(2)valueOffset:用于保存字段value的偏移量,用于后面的CAS操作二、构造方法有两个构造方法,如下:AtommicInteger默认的值是0AtomicInteger我们可以创建指定初始值的实例三、compareAndSet()方法Unsafe中的compareAndSwapInt()方法:var1:操作的对象var2:对象中字段的偏移量var4:旧的期望值var5:要修改的值可以看到,这是一个native方法,底层是使用C/C++写的,主要是调用CPU的CAS指令来实现,它能够保证只有当对应偏移量处的字段值是期望值时才更新,即类似下面这样的两步操作:通过CPU的CAS指令可以保证这两步操作是原子的,也就不会出现多线程环境中可能比较的时候value值是a,而到真正赋值的时候value值可能已经变成b了的问题。四、getAndIncrement()方法Unsafe中的getAndAddInt()方法getAndIncrement()方法底层是调用的Unsafe的getAndAddInt()方法,这个方法有三个参数:var1:操作的对象var2:对象中字段的偏移量var4:增量(这里写死为1)这里方法展现了CAS的运行机制:可以看到它是先获取当前的值,然后再调用compareAndSwapInt()尝试更新对应偏移量处的值,如果成功了就跳出循环,如果不成功就再重新尝试,直到成功为止,这就是(CAS+自旋)的乐观锁机制。和这个方法类似的有incrementAndGet()、decrementAndGet()、getAndDecrement()、getAndAdd(int)、addAndGet(int)......这些方法的底层都是调用了Unsafe类的compareAndSwapInt()方法,这里参照理解即可。五、getAndUpdate(IntUnaryOperatorupdateFunction)getAndUpdate()方法的参数类型是一个函数式接口,我们可以使用lambda定义各种计算操作,比如像下边实例:方法显示获得当前value的值,然后通过自定义的lambda操作计算出要修改的值,之后还是通过调用了Unsafe类中的compareAndSwapInt()实现了原子操作。与他有一个类的的方法updateAndGet()实现手段基本一样,只是返回值不同而已。总结(1)AtomicInteger维护了一个volaitle修饰的int类型的变量value来存储实际的值,这是CAS和voaltile的金典结合,既保证了变量修改的原子性,同时有保证了可见性。(2)AtomicInteger的核心方法都最终与Unsafe中的compareAndSwapInt()方法有关(CAS),这个方法保证了变量修改的原子性。(3)AtomicInteger是CAS在Java中的典型实现,看上去很完美,但是它却没有解决CAS带来的一个大问题:ABA问题至于ABA问题在Java中如何解决,请参考我的另一篇博客深入源码分析原子类AtomicStampedReference

    LoveIT 2020-04-06
    Java多线程与高并发
  • 深入理解CAS无锁机制

    深入理解CAS无锁机制

    一、什么是CAS?CAS(CompareAndSweap或者CompareAndSet,比较替换),CAS和volatile的读写共同支撑起了整合JUC包。工作原理是:一个CAS操作有三个操作数,目标内存地址V,旧的预期值E和将要替换的新值B,在进行替换操作前比较当且仅当目标内存地址V中的值和旧的预期值E值相等的时候才会发生替换,否则什么都不做。CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。二、CAS的原理在Java中通过调用Unsafe类中的JNI代码来支持CAS。它就是借助C/c++调用底层汇编指令实现的。下面从分析比较常用的CPU(intelx86)来解释CAS的实现原理。下面是sun.misc.Unsafe类中几个支持CAS的方法。我们以compareAndSweapInt()为例,这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomic_windows_x86.inline.hpp(windows实现)或atomic_linux_x86.inline.hpp(Linux实现)。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intelx86处理器的源代码的片段:如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lockcmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。intel手册对lock前缀指令的说明如下:确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium4,IntelXeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(areaofmemory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cacheline)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cachelocking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。禁止该指令与之前和之后的读和写指令重排序。把写缓冲区中的所有数据刷新到内存中。关于CPU的锁有如下3种:处理器自动保证基本内存操作的原子性  首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存当中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。奔腾6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器不能自动保证其原子性,比如跨总线宽度,跨多个缓存行,跨页表的访问。但是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。使用总线锁保证原子性  第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。​原因是有可能多个处理器同时从各自的缓存中读取变量i,分别进行加一操作,然后分别写入系统内存当中。那么想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。  处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占使用共享内存。使用缓存锁保证原子性  第二个机制是通过缓存锁定保证原子性。在同一时刻我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,最近的处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。  频繁使用的内存会缓存在处理器的L1,L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在奔腾6和最近的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”就是如果缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效。  但是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cacheline),则处理器会调用总线锁定。第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。  以上两个机制我们可以通过Inter处理器提供了很多LOCK前缀的指令来实现。比如位测试和修改指令BTS,BTR,BTC,交换指令XADD,CMPXCHG和其他一些操作数和逻辑指令,比如ADD(加),OR(或)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。三、CAS的缺陷CAS可以高效的保证原子性,但是CAS任然存在三大问题:ABA问题、循环时间长开销大和只能保证一个变量的原子性。1.ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会觉得它的值没有发生变化,但是实际上发没发生变化对于当前线程是不可见的。ABA问题的解决思路就是在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A就会变成1A-2B-3A。ABA问题Java提供的解决方案jdk1.5Java提供了AtomicStampedReference类,此类通过一个stamp字段来控制版本,下面是此类中的核心方法compareAndSetexpectedReference:表示预期值newReference:新值expectedStamp:预期版本号newStamp:更新后的版本号(1)首先我们可以看看Pair的实现可以看到,Pair是个静态内部类,他是用来保存预期stamp和预期值引用reference的。在AtomicStampedReference内部有一个Pair对象,并且是被volatile修饰的,保证了在多个线程之间的可见性。之后就是比较当前pair中的值和预期值是否相等,pare中的时间戳是否和预期时间错相等,如果都相等,最终调用了casPair`方法,源码如下,主要就是通过Unsafe类来更新版本号和新的值。至于Unsafe类调用的compareAndSwapObject方法那是一个native方法,在Java源码层面是看不到了,所以就不往下追了。2.循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memoryorderviolation)而引起CPU流水线被清空(CPUpipelineflush),从而提高CPU的执行效率。3.只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。四、CAS在Java中的应用——Java原子类1、原子更新基本类型J.U.C包下主要提供了以下类可以原子的更新基本类型:(1)AtomicInteger:原子的更新int类型(2)AtomicLong:原子的更新long类型(3)AtomicBoolean:原子更新布尔类型,内部使用int类型的value存储1和0表示true和false,底层也是对int类型的原子操作。这些类的基本操作和API都很类似,底层都是对Unsafe类的compareAndSwapXxxx()方法的调用,下面是基本的使用:具体的源码分析请参考我的另一篇博文深入源码分析原子类AtomicInteger。2、原子更新引用类型J.U.C包下提供了以下类可以用于原子的更新引用类型的引用:(4)AtomicReference<V>:原子更新引用类型,通过泛型指定要操作的类,存在ABA问题。(5)AtomicMarkableReference<V>:原子更新引用类型,内部使用Pair承载引用对象及是否被更新过的标记,避免了ABA问题。(6)AtomicStampedReference<V>:原子更新引用类型,内部使用Pair承载引用对象及更新的时间戳,避免了ABA问题。具体的源码分析请参考我的另一篇博文深入源码分析原子类AtomicStampedReference。3、原子更新数组J.U.C包下提供了以下类:(7)AtomicIntegerArray:原子更新int类型数组。(8)AtomicLongArray:原子更新long类型数组。(9)AtomicReferenceArray:原子更新引用类型数组。这几个类的操作基本类似,更新元素时都要指定在数组中的索引位置,基本用法如下:4、原子更新对象中的字段原子更新对象中的字段,可以更新对象中指定字段名称的字段,这些类主要有:(10)AtomicIntegerFieldUpdater:原子更新对象中的int类型字段。(11)AtomicLongFieldUpdater:原子更新对象中的long类型字段。(12)AtomicReferenceFieldUpdater:原子更新对象中的引用类型字段。这几个类的操作基本类似,都需要传入要更新的字段名称,基本用法如下:5、高性能原子类高性能原子类是java8中增加的原子类,它们使用分段的思想(Cell[]),把不同的线程hash到不同的段上去更新,最后再把这些段的值相加得到最终的值,相对Atomic类这些类运行性能更高,这些类主要有:(1)Striped64:下面四个类的父类。(2)LongAccumulator:long类型的聚合器,需要传入一个long类型的二元操作,可以用来计算各种聚合操作,包括加减乘除模。(3)LongAdder:long类型的累加器,LongAccumulator的特例,只能用来计算加法,且从0开始计算。(4)DoubleAccumulator:double类型的聚合器,需要传入一个double类型的二元操作,可以用来计算各种聚合操作,包括加减乘除模。(5)DoubleAdder:double类型的累加器,DoubleAccumulator的特例,只能用来计算加法,且从0开始计算。这几个类的操作基本类似,其中DoubleAccumulator和DoubleAdder底层其实也是用long来实现的,基本用法如下:具体的源码分析请参考我的另一篇博文深入源码分析高性能原子类LongAdder。

    LoveIT 2020-04-06
    Java多线程与高并发
  • 都2020年了你还不理解volatile关键字?

    都2020年了你还不理解volatile关键字?

    先给出结论:volatile可以保证不同线程对一个变量的可见性,即一个线程修改了被voaltile修饰的变量后,这新值对其他线程来说是立即可见的;volatile可以禁止指令重排序,保证了有序性;volatile不能保证原子性,更加不能保证线程安全,要想保证原子性和线程安全还是乖乖的使用锁吧!具体的原理且看我慢慢分析。一、Java内存模型JMM即JavaMemoryModel,它将JVM内存抽象为主内存和工作内存。主内存是所有的线程共享的内存区域;工作内存是线程私有的。JMM规定:所有的变量都存储在主内存中(虚拟机内存的一部分),对于所有线程都是共享的。每个线程都有自己的工作内存,工作内存中保存的是该线程使用到的变量副本(该副本就是主内存中该变量的一份拷贝),线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成屏蔽硬件和操作系统内存读取差异,以达到各个平台下都能达到一致的内存访问效果的产物。并且通过JMM使得程序员不必接触底层的CPU缓存、寄存器、硬件内存、CPU指令优化等。JMM的作用体现在以下几个方面:(1)原子性​在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。并且Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。(2)可见性 对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。(3)有序性​在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行(as-if-serial语义),却会影响到多线程并发执行的正确性。在Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。二、volatile实现的原理1、volatile保证可见性的原理(1)Lock前缀指令volatile可见性的实现原理是基于lock前缀指令实现的。上面的代码,我们添加hsdis插件到JRE的lib目录后,可以对上述代码进行反汇编打印出来的汇编指令如下:核心就是lock指令。可以说,lock指令就是CPU实现volatile可见性的重点。通过查IA-32架构软件开发者手册,内容如下:8.1.4EffectsofaLOCKOperationonInternalProcessorCachesFortheIntel486andPentiumprocessors,theLOCK#signalisalwaysassertedonthebusduringaLOCKoperation,eveniftheareaofmemorybeinglockediscachedintheprocessor.FortheP6andmorerecentprocessorfamilies,iftheareaofmemorybeinglockedduringaLOCKoperationiscachedintheprocessorthatisperformingtheLOCKoperationaswrite-backmemoryandiscompletelycontainedinacacheline,theprocessormaynotasserttheLOCK#signalonthebus.Instead,itwillmodifythememorylocationinternallyandallowit’scachecoherencymechanismtoensurethattheoperationiscarriedoutatomically.Thisoperationiscalled“cachelocking.”Thecachecoherencymechanismautomaticallypreventstwoormoreprocessorsthathavecachedthesameareaofmemoryfromsimultaneouslymodifyingdatainthatarea.翻译过来即就是:对于Intel486和Pentium处理器,即使正在锁定的内存区域已缓存在处理器中,在LOCK操作期间始终会在总线上发出LOCK#(以lock为前缀的指令)信号。对于P6和更新的处理器家族,如果在执行LOCK操作的处理器中缓存了在LOCK操作期间锁定的内存区域作为回写内存,并且完全包含在缓存行中,则处理器可能不会声明总线上的LOCK#信号。取而代之的是,它将在内部修改内存位置,并允许其缓存一致性机制来确保该操作是原子执行的。该操作称为“缓存锁定”。缓存一致性机制会自动阻止已缓存同一内存区域的两个或更多处理器同时修改该区域中的数据。inthePentiumandP6familyprocessors,ifthroughsnoopingoneprocessordetectsthatanotherprocessorintendstowritetoamemorylocationthatitcurrentlyhascachedinsharedstate,thesnoopingprocessorwillinvalidateitscachelineforcingittoperformacachelinefillthenexttimeitaccessesthesamememorylocation.翻译过来就是:在Pentium和P6系列处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处于共享状态,那么正在嗅探的处理器将使它的缓存行无效,在下次访问相同内存地址时,强制执行缓存行填充一个处理器的缓存回写到内存会导致其他处理器的缓存无效。上述引用总结为volatile的两条实现原则:(1)对缓存行加锁内容的修改会导致修改后的内容马上写回主内存;(2)一个处理器的缓存回写到主存会使其他缓存了该共享变量的缓存失效。(2)MESI(缓存一致性协议)当CPU写数据的时候,如果发现操作的数据是共享变量,即在其他CPU中也有这个变量的副本,那么将会发出信号通知其他CPU将关于该变量的缓存行设置为无效,此后当其他CPU使用该变量时,发现自己的缓存中该数据的缓存行已经无效,那么就会重新到主内存读取。至于是怎么发现数据是否失效呢?(3)嗅探每个处理器嗅探在总线上传播过来的数据,如果发现自己缓存行对应的内存地址被修改,那就会把该缓存行设置为无效,当处理器对这个数据进行操作的时候,会重新从主内存中将数据读取到处理器的缓存中。总线嗅探有何缺点?由于volatile的缓存一致性协议,需要的不断的从主内存嗅探以及CAS自旋,无效的交互过多就会造成总线带宽达到峰值。因此不建议大量使用volatile变量。2、volatile实现禁止指令重排序的原理(1)什么是指令重排序?为什么要指令重排序?现代CPU在执行一条指令的分为5个阶段:取指令-->指令译码-->执行指令-->内存访问-->数据写回。为了提高指令执行的吞吐量,现代CPU都支持多级指令流水线(一般就是五级指令流水线),就是可以在一个时钟周期内同时对5个指令执行不同阶段的操作,本质上,流水线方式不能缩短一条指令执行的时间,但它提高了CPU的吞吐率。因此,在流水线模式下,为了提高CPU的指令执行效率以及CPU的吞吐率,就会对指令重新排序,让指令能够尽量不间断的送给CPU处理,当然重排序的前提是重排序前后执行的结果不变。指令重排序分为编译器优化重排序、指令并行重排序和内存系统重排序,他们实现重排序的目的都是为了提高程序的效率。编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;指令并行重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序,处理器的重排序又被称为乱序执行(out-of-orderexecution,OOE)技术;内存系统重排序。由于处理器使⽤缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执⾏的。(2)as-if-serial语义as-if-serial语义的意思是:无论如何重排序,单线程内程序的执行结果不能被改变。编译器、runtime和处理器都必须遵循as-if-serial语义。为了遵守这条语义,编译器和处理器不会对存在数据依赖关系的操纵进行重排序,反之,就会被重排序。(3)指令重排序带来的问题指令重排序的本质是好的,但是在某些时候会导致一些错误。请看下面这段代码,这是美团技术团队博客上给出的一段代码,它描述了由于指令重排序带来的问题。执行结果:当执行到第649625次的时候发生了错误,为什么说是错误,正常情况下这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。也就是说,(0,0)这个结果是如果是按正常顺序执行的话是不可能出现的,但是现在出现了就证明了一件事儿,CPU确实存在对指令的重排序。(4)volatile禁止指令重排序原理为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。但是在使用volatile之后就会禁止指令重排序,原理就是通过内存屏障来实现的。Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。JMM会针对编译器制定volatile重排序规则表,如下:其中“NO”表示禁止指令重排序,为了实现这一语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。需要注意的是:volatile写是在前面和后面分别插入内存屏障,volatile读操作是在后面插入两个内存屏障。volatile写StoreStore:对于这样的语句Store1;StoreStore;Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。StoreLoad:对于这样的语句Store1;StoreLoad;Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。(开销最大,对于大多数处理器是万能屏障,它兼具其他三种屏障的功能)volatile读LoadLoad:对于这样的语句Load1:LoadLoad:Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。LoadStore:对于这样的语句Load1;LoadStore;Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。其实前面的lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:(1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(2)它会强制将对缓存的修改操作立即写入主存;(3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

    LoveIT 2020-04-04
    Java多线程与高并发
  • 高并发编程之LockSupport深入源码剖析

    高并发编程之LockSupport深入源码剖析

    与Object的wait()/notify()相比:wait()、notify()、notifyAll()都必须配置Objectmonitor使用,而LockSupport的park()、unpark()方法不必park()、unpark()可以精确到一个线程来阻塞和唤醒它,但是notify()只能在WaitSet中随机唤醒一个,并且notifyAll()也只能是唤醒所有的线程,控制精度不及unpark()。一个线程执行unpark()之前可以调用park(),并且线程执行的效果和先调用park()再调用unpark()一致。1、LockSupport的基本使用LockSupport中有一组方法可以实现对某一个线程精确的阻塞和唤醒,即:park()及他的重载方法和unpark()(1)正常使用:先阻塞再唤醒执行结果:(2)先唤醒再阻塞执行结果:可以看到,这次是先执行了唤醒操作,之后执行了阻塞操作,但是程序并没有像我们预期的那样阻塞,而是正常执行结束了。这是为什么呢?接下来我们就深入到源码中探寻真相!!!二、park()、unpark()原理剖析在JavaAPI层面很简单,最终都是调用了UnSafe类的对应的本地方法,为了探究底层原理,我找到了关于这个用C++实现的Parker类,**每一个线程都有一个与之关联的Parker对象,Parker对象主要由__counter、__cond和__mutex组成。**下面是Parker类的部分源码:LockSupport就是通过Parker类中的__counter实现对一个线程的阻塞和唤醒的。调用park()示意图Parker::park():park的流程如下:step1.如果有许可可用,则将_counter原子地设置为0,并直接返回。xchg返回的是旧的_counter;否则将没有许可可用。step2.获取当前线程,如果当前线程设置了中断标志,则直接返回,因此如果在park前调用了interrupt就会直接返回。step3.获取定时时间,安全点;如果中断或获取_mutex失败,则直接返回step4.如果_counter=1,说明之前unpark已经调用过了。所以只需将_counter置为0,解锁返回(表现在程序上就是在执行LockSupport.park()后不会被阻塞)。step5.对于time=0,pthread_cond_wait(&_cond[_cur_index],_mutex)直接挂起;对于定时的,挂起指定的时间status=os::Linux::safe_cond_timedwait(&_cond[_cur_index],_mutex,&absTime);调用unpark()示意图Parker::unpark()unpark的运行流程:step1.对_mutex加锁,并将_counter置为1。step2.如果之前的_counter为0则说明调用了park或者为初始状态(此时为0且没有调用park)。__step2-1.当前parker对应的线程挂起了。因为_cur_index初始化为-1,且线程唤醒后也会重置为-1。调用pthread_cond_signal(&_cond[_cur_index])。调用pthread_mutex_unlock(_mutex)step2-2.没有线程在等待条件变量,则直接解锁:pthread_mutex_unlock(_mutex);step3.如果之前的_counter为1,则说明线程调用了一次或多次unpark但是没调用park,则直接解锁。分析了这么多,总结就是:在调用park的时候如果__counter是0则会去执行挂起的流程,否则返回,在挂起恢复后再将counter置为0。在unpark的时候如果__counter是0则会执行唤醒的流程,否则不执行唤醒流程,并且不管什么情况始终将_counter重置为1。在park里,调用pthread_cond_wait时,并没有用while来判断,所以posixcondition里的"Spuriouswakeup"一样会传递到上层Java的代码里(因为条件需要Java层才能提供)。这也就是为什么Javados里提到需要注意虚假唤醒的情况。

    LoveIT 2020-04-03
    Java多线程与高并发
  • 高并发编程之等待/通知机制

    高并发编程之等待/通知机制

    一、什么是等待/通知机制?等待/通知机制就是一个【线程A】等待,一个【线程B】通知(线程A可以不用再等待了)。比如生产者和消费者模型,消费者等待生产者生产资源,这是等待,生产者生产好资源通知等待的消费者去消费,这是通知。等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在java.lang.Object类中注意!(1)notify()或notifyAll()在调用之后,等待线程不会立即从WAITING状态立即变为RUNNING状态,而是需要等到调动notify()或notifyAll()的方法释放对象锁之后才会从WAITING状态返回。(2)wait(long,int)这个方法其实不能精确到ns,这一点从源码就可以看到,他只是在前面的参数上加了1ms:二、等待/通知机制的经典范式(模板)(1)等待方(消费者)需遵循如下原则:获取对象锁如果条件不满足,那么调用对象的wait()方法,被通知后仍然要检查条件条件满足则执行对应逻辑(2)通知方(生产者)需遵循如下原则:获得对象锁改变条件通知所有等待该对象锁的线程三、异步模型——生产者和消费者模型要点:消费队列可以用来平衡生产和消费的线程资源生产者专心生产资源,不关心数据如何处理,消费者专心消费资源消息队列是由容量限制的,当容量满了以后生产者停止生产,当空了后消费者停止消费JDK中各种阻塞队列使用的就是这种模式实现生产者消费者资源:StockBlockQueue.javaStock接口生产者:Producer.java消费者:Customer.java其实生产者消费者模式的实现方式还有很多种,这里就展示了一种基于阻塞队列实现的生产者消费者模式的方式,其他几种实现方式可以参考我的另一篇文章生产者消费者模式四、最后总结一下1、等待/通知机制Java中这一机制的实现是wait()/notify()。wait():会将当前线程放入等待队列,让当前线程停止运行直到有其他线程唤醒或者被中断。在调用wait()方法时,当前线程必须已经获得锁,即只能与synchroinzed中使用,执行wait()方法后当前线程会释放锁。notify():只能和synchronized配合使用,当此方法执行后会有由线程规划器随机唤醒一个等待中的线程,执行notify()之后,当前线程不会立即释放锁,被唤醒的线程也不会立即获得该对象锁,而是进入到就绪状态(进入到Monitor的EentryList中)准备竞争此对象锁,如果竞争锁失败,此线程也除非再次代用wait(),否者不会再被放到等待队列中了。2、sleep()和wait()的区别(1)sleep()是Thread类中的方法,wait()是Object()中的方法,因此所有对象都有这个方法(2)wait()必须配合synchronized使用,但是sleep()没有这个要求(3)如果当前线程持有锁,那么调用sleep()方法之后当前线程不会释放锁,此时其他线程四无法获取这个锁的,但是调用wait()方法会释放锁,其他线程可获得该对象锁3、Java中wait()和notify为什么定义在Object类中而不是在Thread类中?Object中的wait(),notify()等函数,和synchronized一样,会对“对象的同步锁”进行操作。wait()会使“当前线程”等待,因为线程进入等待状态,所以线程应该释放它锁持有的“同步锁”,否则其它线程获取不到该“同步锁”而无法运行!OK,线程调用wait()之后,会释放它锁持有的“同步锁”;而且,根据前面的介绍,我们知道:等待线程可以被notify()或notifyAll()唤醒。现在,请思考一个问题:notify()是依据什么唤醒等待线程的?或者说,wait()等待线程和notify()之间是通过什么关联起来的?答案是:依据“对象的同步锁”。负责唤醒等待线程的那个线程(我们称为“唤醒线程”),它只有在获取“该对象的同步锁”(这里的同步锁必须和等待线程的同步锁是同一个),并且调用notify()或notifyAll()方法之后,才能唤醒等待线程。虽然,等待线程被唤醒;但是,它不能立刻执行,因为唤醒线程还持有“该对象的同步锁”。必须等到唤醒线程释放了“对象的同步锁”之后,等待线程才能获取到“对象的同步锁”进而继续运行。总之,notify(),wait()依赖于“同步锁”,而“同步锁”是对象锁持有,并且每个对象有且仅有一个!这就是为什么notify(),wait()等函数定义在Object类,而不是Thread类中的原因。

    LoveIT 2020-04-03
    Java多线程与高并发
  • 作为Java工程师你真的理解synchronized吗

    作为Java工程师你真的理解synchronized吗

    造成线程安全问题的诱因主要有两点:存在共享数据(临界区资源)存在多个线程对临界区资源的读写操作因此,为了保证临界区数据的安全,引入了互斥锁的概念,即一个共享数据同时只能被一个线程访问,其他线程需要等待(阻塞),直至当前线程处理完毕释放该锁。synchronized就保证了同一时刻只有一个线程对方法或者代码块的共享数据的操作。而且,synchronized由于他的互斥性间接的保证了一个线程对共享变量操作的变化被其他线程看到。一句话介绍synchronized:synchronized是一个Java关键字,可以用来修饰方法和代码块,主要的作用就是保证同一时刻只能有一个线程执行临界区中的代码,以此达到线程安全的效果。一、synchronized修饰方法和代码块synchronized是Java语言内置的锁,可以用于代码块和方法(静态方法和普通成员方法)1、synchronized代码块编译上面的代码,然后反编译.calss文件:javap-verboseSynchronizedTest,得到如下结果:从反编译的结果可以看到:同步代码块在字节码层面通过monitorenter和monitorex两条字节码指令实现的。当进入方法执行到monitorenter指令时,当前线程会尝试获取Monitor的所有权,成为这个Monitor的Owner,获取成功后继续执行临界区中的代码,执行结果会执行monitorexit指令,表示释放锁;如果获取锁失败那就会进入阻塞队列中等待;如果当前线程已经是此Monitor的Owner了,再次执行到monitorenter那就直接进入,并将进入次数+1同理,当执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有2、synchronized成员方法synchronized修饰成员方法,实际上是以当前对象为锁,进入同步代码前要获得当前对象实例的锁。因此并不存在给方法上锁的说法。3、synchronized静态方法由于静态方法是属于类的,因此当我们用synchronized修饰静态方法时,实际上是以这个类的Class对象为锁,又由于Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁。方法的同步:在方法常量表中记录一个ACC_SYNCHRONIZED访问标记,调用指令会检查方法的常量表中是否设置了ACC_SYNCHORINZED标记,如果设置了这个标志,执行线程就需要先获取Monitor然后才能执行方法,最后方法执行完毕释放Monitor。在方法执行期间,执行线程持有了该Monitor之后,其他线程就无法再获取了。如果在执行同步方法期间发生了异常,并且方法内部无法处理此异常,那么同步方法将会在抛出异常后自动释放Monitor。二、Monitor概念Monitor,意为监视器或管程。monitor的重要特点是,同一个时刻,只有一个进程/线程能进入monitor中定义的临界区,这使得monitor能够达到互斥的效果。但仅仅有互斥的作用是不够的,无法进入monitor临界区的进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。显然,monitor作为一个同步工具,也应该提供这样的管理进程/线程状态的机制。在JVM中,每一个对象头都关联着Monitor(由操作系统提供),每一个监视器和一个对象引用相关联,为了实现监视器的互斥功能,每个对象都关联着一把锁(有时候又叫“互斥量,mutex”,信号量)。一旦方法或者代码块被synchronized修饰,那么这个部分就放入了监视器的监视区域,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码。这里多次提到Monitor,并且网上很多文章在提到synchornized的底层原理的时候也就截止到Monitor了,大家是不是以为Monitor就是个虚无的概念性的东东,其实不是,Hotsopt实现的Monitor是基于C++实现的一个叫ObjectMonitor.hpp文件中定义着。感兴趣的同学可以在Hotspot源码的\src\share\vm\runtime路劲下找到这个文件,可以去看看。下图是他的构造方法的样子:把上面代码中的核心组件抽象一下我们可以看到如下示意图:说明:上图中Thread-2就是Monitor的拥有者,Thread-1,Thread-3获取Monitor失败被阻塞在阻塞队列中1、synchronized实现的核心组件WaitSet:调用wait方法被阻塞的线程被放置在这里;ContentionList:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;EntryList:竞争队列中那些有资格成为候选资源的线程被移动到EntryList中;OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;Owner:当前已经获取到所资源的线程被称为Owner!Owner:当前释放锁的线程。2、synchronized的实现原理(1)JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。(2)Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。(3)Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。(4)OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。(5)处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。(6)Synchronized是非公平锁。synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。(7)每个对象都有个monitor对象,加锁就是在竞争monitor对象,代码块加锁是在前后分别加上monitorenter和monitorexit指令来实现的,方法加锁是通过一个ACC_SYNCHRONIZED标记位来判断的。但是无论是同步代码块,还是同步方法,在汇编指令层面都是通过lockcmpxchg这条汇编指令实现,在操作系统层面是通过mutexlock指令实现的。(8)jdk1.6以前的synchronized是一个重量级操作,需要通过系统调用实现,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。(9)jdk1.6及以后,官方对synchronized进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的jdk1.7与1.8中,均对该关键字的实现机理做了优化。引入了偏向锁和轻量级锁。都是在对象头中做标记位,少量的竞争不在需要系统调用来支持,紧在用户态就可以实现互斥性。(10)锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀;(11)jdk1.6中默认开启了偏向锁和轻量级锁,可以通过JVM参数:-XX:-UseBiasedLocking来禁用偏向锁。三、Java虚拟机对synchronized的优化jdk1.6之前,synchronized是一个重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的互斥锁来实现的,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。jdk1.6以后Java的锁有4种状态:无锁状态、偏向锁、轻量级锁(自旋锁)和重量级锁(如下图所示)。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,锁可以升级,但是锁不能降级,这种策略是为了提高获得锁和释放锁的效率。可以看到,在Hotspot的实现中,通过对象头后两位可以区分各种锁的状态(偏向锁需要后三位):001表示无锁状态101表示偏向锁状态00表示轻量级锁状态10表示重量级锁synchronized锁膨胀过程(1)无锁状态刚new出来的对象就处于无锁状态。此时一个对象的对象头的MarkWord中主要存储的是对象的hashcode,只不过在主动调用hashcode()方法之前默认值都是0,代用之后才会存储真正的hashCode值、对象的分代年龄、最后三位001就可以标志这是一个没有锁的对象。(2)偏向锁偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,没有其他线程来获取锁,则持有偏向锁的线程永远不需要在进行同步。偏向锁可以提高带有同步但无竞争的程序的性能。偏向锁的获取当一个线程第一次获取偏向锁的时候,会使用CAS操作把获取到偏向锁的线程ID记录到在锁对象对象头的MarkWord,如果CAS操作执行成功,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需简单地测试一下对象头的MarkWord里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。偏向锁的撤销持有偏向锁的线程只有在竞争出现才会释放锁。当其他线程尝试竞争偏向锁时,当前线程到达全局安全点后(没有正在执行的代码),它会查看锁对象头中记录的线程是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。偏向锁在JDK1.6之后是默认开启的,但是它有一个延迟,会在程序启动后几秒后才会激活,也可以通过JVM参数关闭延迟:​-XX:BiasedLockingStartupDelay=0当程序中的锁竞争是很可能发生的事情的时候可以直接使用JV命名关闭偏向锁:​-XX:UseBiasedLocking=false(3)轻量级锁轻量级锁能够带来同步程序的性能的依据是:“对于大部分的锁,在整个同步周期类都是不存在竞争的”,这是一个经验数据。即:如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量带来对开销,但是如果存在竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比重量级锁更慢。加锁当锁升级为轻量级锁之后,会在该线程的桟帧中产生创建一块锁记录空间,并把对象头中的MarkWord复制到锁记录中,被称为DisplacedMarkWord。然后线程尝试使用CAS操作将对象头中的MarkWord替换为指向锁记录的指针。如果成功,当前线程获得锁,随后将锁标志设为00;如果CAS操作失败,表示其他线程竞争锁,当前线程将进入到自旋获取锁的过程。自旋是一个耗费处理器资源的行为,因此当自旋一定次数后,锁就会升级为重量级锁,当前线程就会被阻塞,自旋默认的次数是10次,可以通过JVM参数-X:preBlockSpin来修改。在jdk1.6中引入了自适应的自旋锁。自适应的锁的自旋时间不是固定的,他会根据上一次在同一锁上的自旋时间以及拥有者的状态来决定。如果在同一个自旋锁对象上,自旋等待成功获取了锁并且正在运行,那么JVM就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续更长时间。如果对于某个锁,自旋成功很少获取到锁,那么JVM就会省略自旋过程,以避免浪费处理器资源。解锁轻量级解锁时,会使用CAS将锁对象当前的MarkWord和线程中复制的DisplacedMarkWord替换,如果成功,整个同步过程就结束了。如果失败,表示又其他线程尝试获取过该锁,那就会在释放锁的同时唤醒被挂起的线程。(4)重量级锁当多个线程请求获取轻量级锁的时候,轻量级锁就会膨胀为重量级锁,重量级锁就是指当一个线程获得锁之后,其余等待这个锁的线程都将进入到阻塞状态。重量级锁是操作系统层面的锁,此时线程的调度都将由操作系统负责,因此这就会引起频繁的上下文切换,导致线程被频繁的唤醒和挂起,使得程序性能下降。重量级锁的底层实现在JVM层面是通过对象内部的监视器(monitor)实现的。各种锁的比较:四、锁降级、锁消除、锁粗化1、锁降级(不重要)锁降级在某些特定情况下回发生,就是在GC的时候会发生,但是此时锁降级也就没有意义了,因此锁降级可以认为是不存在的。2、锁消除lockeliminate**锁消除是指虚拟机在即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。**锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。3、锁粗化lockcoarsening原则上,我们在使用同步块的时候总是推荐将同步块的作用范围限制尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待所的线程也可以快速的拿到锁。大部分情况下,上面的原则是正确的,但是如果一一系列的连续操作都需要对同一个对象反复加解锁,甚至加锁操作在出现在循环体的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。对于这种情况,JVM就会优化,把锁的范围扩大,这就是锁优化。五、synchronized可重入锁实现底层原理1、重入的概念?可重入就是说若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。通俗来说:当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。2、synchronized实现可重入锁的原理或机制Java中每一个对象锁中都会和一个monitor关联,monitor中有一个计数器就是专门用来记录线程的重入次数的,当此计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而执行相应的临界区代码;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增+1;当线程退出同步代码块时,计数器会递减-1,如果计数器为0,则表示当前线程释放了该锁。

    LoveIT 2020-04-02
    Java多线程与高并发
  • 面试题:Java中如何优雅的停止线程?

    面试题:Java中如何优雅的停止线程?

    如何停止线程是Java并发面试中的常见问题,这里总结一下。答题思路:停止线程的正确方式是使用中断想停止线程需要停止方,被停止方,被停止方的子方法相互配合扩展到常见的错误停止线程方法:已被废弃的stop/suspend,无法唤醒阻塞线程的volatile标记位方式1、正确的方式:使用interrupt()安全的终止程序(推荐)关于使用interruput()方法来终止线程,最佳的说明文档就是javadoc了。在这个方法的javadoc上说明了在使用interruput()的三种情况:(1)如果当前线程处于阻塞状态:比如调用了sleep()、wait()以及重载方法、join()以及重载方法或者其他操作让当前线程进入到阻塞状态,此时调用interrupt(),那么它的中断状态会被清除为false并且会收到一个InterruptedException异常比如一个线程调用了wait()方法后处于阻塞状态,此时别处调用interrupt()后会立即将线程的中断标记设为“true”,但是由于线程处于阻塞状态,所以该“中断标记”会立即被清除为“false”,同时,会产生一个InterruptedException的异常。(2)如果线程被阻塞在一个Selector选择器中,那么通过interrupt()中断它时,线程的中断标记会被设置为true,并且它会立即从选择操作中返回。(3)线程未处于阻塞状态,那么通过interrupt()中断线程时,它的中断标记会被设置为“true”第一个方法是通过循环不断判断自身是否产生了中断:在上面的代码中,我们在循环条件中不断判断线程本身是否产生了中断,如果产生了中断就不再打印。还有一个方法是通过java内定的机制响应中断:当线程调用sleep(),wait()方法后进入阻塞后,如果线程在阻塞的过程中被中断了,那么线程会捕获或抛出一个中断异常,我们可以根据这个中断异常去控制线程的停止。具体代码如下:2、各方配合才能完美停止在上面的两段代码中已经可以看到,想通过中断停止线程是个需要多方配合。上面已经演示了中断方和被中断方的配合,下面考虑更多的情况:假如要被停止的线程正在执行某个子方法,这个时候该如何处理中断?有两个办法:第一个是把中断传递给父方法,第二个是重新设置当前线程为中断。第一个例子:在子方法中把中断异常上抛给父方法,然后在父方法中处理中断第二个例子:在子方法中捕获中断异常,但是捕获以后当前线程的中断控制位将被清除,父方法执行时将无法感知中断。所以此时在子方法中重新设置中断,这样父方法就可以通过对中断控制位的判断来处理中断3、常见错误停止线程例子这里介绍两种常见的错误,先说比较好理解的一种,也就是开头所说的,在外部直接调用Thread类的stop()方法把运行中的线程停止掉。这种暴力的方法很有可能造成脏数据,就象突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果,不安全主要是:thread.stop()调用之后,创建子线程的线程就会抛出ThreadDeatherror的错误,并且会释放子线程所持有的所有锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因此,并不推荐使用stop方法来终止线程。在上面的例子中,我们模拟军队发放武器,规定一个连为一个单位,每个连有10个人。当我们直接从外部通过stop方法停止武器发放后。很有可能某个连队正处于发放武器的过程中,导致部分士兵没有领到武器。这就好比在生产环境中,银行以10笔转账为一个单位进行转账,如果线程在转账的中途被突然停止,那么很可能会造成脏数据。另外一个常见错误就是:通过volatile关键字停止线程。具体来说就是通过volatile关键字定义一个变量,通过判断变量来停止线程。这个方法表面上是没问题的,我们先看这个表面的例子上面的代码可以正常执行,但是这个方法有一个潜在的大漏洞,就是若线程进入了阻塞状态,我们将不能通过修改volatile变量来停止线程,看下面的生产者消费者例子:上面的例子运行后会发现生产线程一直不能停止,因为他处于阻塞状态,当消费者线程退出后,没有任何东西能唤醒生产者线程。这种错误用中断就很好解决:参考资料【1】面试题:Java中如何停止线程的方法

    LoveIT 2020-04-01
    Java多线程与高并发
  • Thread和Runnable深入源码剖析

    Thread和Runnable深入源码剖析

    一、Runnable的实现类Thread类本质是Runnable的一个实现类,Runnable从jdk1.0就有了,并且在jdk1.8开始成为了一个函数式接口,这使得我么可以使用lambda简化线程的创建。二、Thread类的构造方法所有构造方法都是调用init()方法来完成对一个线程的初始化,下面是init方法的jdk源码:从源码中可以看出下面几点:每个线程都会有一个线程名,默认的名字是"Thread-"+nextThreadNum();每个新创建的线程的父线程就是当前线程;如果一个线程我们没有指定线程组,那么他会和父线程在同一线程组;如果父线程是守护线程,那么新创建的这个线程也是守护线程,反之同理;新创建的线程的优先级默认和父线程的优先级相同;currentThread()==>native方法,获取当前线程默认线程名是"Thread"+nextThreadNum,线程名可以通过setName()方法修改,并且可以通过方法getName()方法获取到线程名nextThreadNum()==>同步方法,维护了一个成员变量threadInitNumber,就是一个线程初始化的数字,没创建一个线程就+1**设置线程名和获得线程名==>**setName()getName()三、线程的优先级和线程状态注意!一个线程在被从Object.wait()中被唤醒时,会立即进入BLOCKED状态,这时其并没有获得锁,只是被唤醒了,再次开始对Object的监视器锁进行竞争;只有在其竞争获得锁之后才会进入RUNNABLE状态.四、run()和start()run():如果是构造Thread对象的时候,传入了该对象预期执行的任务----Runnable对象时,执行该任务,否则,什么都不做,当然,可以通过继承Thread类重写run()来修改其行为:start():以前面试最爱问的一个问题:调用start()与调用run()的区别?这还用说吗?调用run()只是普通的方法调动,执行的线程还是调用线程;而调用start()可以启动一个线程并是线程出入Runnable状态通过上述start()方法的源码,可以总结出以下几个点:(1)当线程被构造完成之后,有个状态变量会记录线程的状态,就是上述的threadStatus,初始值在类中声明的为0;(2)调用start方法启动线程之后,threadStatus的值会被改变。如果再次重复调用start方法启动同一个线程时,会抛出IllegalThreadStateException异常;(3)thread线程启动之后,会被加入到一个线程组ThreadGroup中;(4)线程如果启动失败了,线程组会对线程做失败处理,将其从线程组中移除;(5)如果一个线程生命周期结束了,再次调用start方法试图启动线程的时候,会提示IllegalThreadStateException异常,也就是处于TERMINATED状态的线程没办法重新回到RUNNABLE或者RUNNING状态;下面是一个Java线程启动分析示意图五、线程中断关于interrupt()方法的细节说明(以下是翻译自javadoc关于interrupt()的说明):(1)interrupt()的作用是中断本线程。本线程中断自己是被允许的;其它线程调用本线程的interrupt()方法时,会通过checkAccess()检查权限。这有可能抛出SecurityException异常。(2)如果本线程是处于阻塞状态:调用线程的wait(),wait(long)或wait(long,int)会让它进入等待(阻塞)状态,或者调用线程的join(),join(long),join(long,int),sleep(long),sleep(long,int)也会让它进入阻塞状态。若线程在阻塞状态时,调用了它的interrupt()方法,那么它的“中断状态”会被清除并且会收到一个InterruptedException异常。例如,线程通过wait()进入阻塞状态,此时通过interrupt()中断该线程;调用interrupt()会立即将线程的中断标记设为“true”,但是由于线程处于阻塞状态,所以该“中断标记”会立即被清除为“false”,同时,会产生一个InterruptedException的异常。(3)如果线程被阻塞在一个Selector选择器中,那么通过interrupt()中断它时,线程的中断标记会被设置为true,并且它会立即从选择操作中返回。(4)如果不属于前面所说的情况,那么通过interrupt()中断线程时,它的中断标记会被设置为“true”。(5)中断一个“已终止的线程”不会产生任何操作。另外关于isInterrupted()和interrupted()的区别,上述源码表现得很明显isInterrupted()在执行后不会清除中断标志位,interrupted()在方法执行后会清除中断标志位,而且是个静态方法。六、线程礼让\睡眠\合并—yield()sleep()join()七、被遗弃的方法——suspend()resume()stop()八、ThreadLocal:在Thread中有两个关于ThreadLocal的成员变量具体的ThreadLocal相关剖析请参考我的另一篇博客对ThreadLocal的理解?ThreadLocal如何解决内存泄漏问题?九、UncaughtExceptionHandler

    LoveIT 2020-03-31
    Java多线程与高并发
  • 对ThreadLocal的理解?ThreadLocal如何解决内存泄漏问题?

    对ThreadLocal的理解?ThreadLocal如何解决内存泄漏问题?

    一、ThreadLocal是什么?ThreadLocal是一个本地线程副本变量工具类。ThreadLocal中填充的变量只属于当前线程,与其他线程无关。ThreadLocal为变量在每个线程中都创建了一个副本,每个线程可以访问自己内部的副本变量。ThreadLocal是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的,这样就可以避免资源竞争带来的多线程的问题,这种解决多线程的安全问题和lock(这里的lock指通过synchronized或者Lock等实现的锁)是有本质的区别的:1.lock的资源是多个线程共享的,所以访问的时候需要加锁。2.ThreadLocal是每个线程都有一个副本,是不需要加锁的。3.lock是通过时间换空间的做法。4.ThreadLocal是典型的通过空间换时间的做法。二、ThreadLocal深入源码剖析在分析源码之前先画一下ThreadLocal,ThreadLocalMap和Thread的关系:由该图可知,Thread类中有一个threadLocals和一个inheritableThreadLocals,它们都是ThreadLocalMap类型的变量,而ThreadLocalMap是一个定制化的Hashmap。在默认情况下,每个线程中的这两个变量都为null,只有当前线程第一次调用ThreadLocal的set或者get方法时才会创建它们。其实每个线程的本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的threadLocals变量里面。也就是说,ThreadLocal类型的本地变量存放在具体的线程内存空间中。ThreadLocal就是一个工具壳,它通过set方法把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里面将其拿出来使用。如果调用线程一直不终止,那么这个本地变量会一直存放在调用线程的threadLocals变量里面,所以当不需要使用本地变量时可以通过调用ThreadLocal变量的remove方法,从当前线程的threadLocals里面删除该本地变量。另外,Thread里面的threadLocals为何被设计为map结构?很明显是因为每个线程可以关联多个ThreadLocal变量。ThreadLocal常用的核心方法下面简单分析ThreadLocal的set、get和remove方法实现逻辑。set方法咱们这里以set(Tvalue)方法为例来探究一下ThreaLocal,下面是set方法的jdk源码流程梳理:1、获取当前线程2、尝试获取当前线程的ThreadLocalMap(就是一个类似于HashMap的东西),如果map不为空,那就把当前线程和value值绑定并保存到map中,否则如果Map是空的,那么就会先实例化一个ThreadLocalMap,然后把value和当前线程绑定加入到map中(从这里可以看到,ThreadLocal采用了懒加载机制,没有一上来就直接new一个ThreadLocalMap,而是在第一次使用的时候再初始化Map,这一点和HashMap的机制类似。)在程序第一次执行set方法的时候,ThreadLocalMap还是null,此时就需要初始化一个ThreadLocalMap,下图所示就是初始化一个ThreadLocalMap的过程。可以看到非常简单的逻辑,就是直接new了一个ThreadLocalMap然后返回。ThreadLocalMap初始化之后就可以正常的获取map和设置值了,我们点击getMap()方法看看map是如何获得到的,如下图所示。ThreadLocal.java可以看到,一个线程之和一个ThreadLocalMap有关联,而且这个还是在Thread类中维护着。Thread.java当获取到ThreadLocalMap不为空的时候,程序添加新值就会执行ThreadLocal内部的一个重载的set方法,在下面的程序中操作Entry数组是这个方法流程的核心:上面set方法的源码中多次用到了Entry数组,那么这个Entry到底是啥呢?原来这个Entry是ThreadLocalMap的内部类,并且他继承的父类很有意思——WeakReference,它表示是一个弱引用,通过前面的分析我们得知,每一对ThreadLocal实例和线程变量副本value都是以一个Entry的形式添加到当前线程的ThreadLoacalMap中的,那它为什么要继承WeakReference呢?其实这和ThreadLocal解决内存泄漏有很大的关系,熟悉WeakReference的同学应该都知道,如果一个实例对象只有弱引用的话,那么一旦发生GC无论当前堆空间是否紧张,这个实例是一定会被GC清除的。为了更加清除的说明请看下图,tl是我们在桟空间的一个引用,他和ThreaLocal是强引用关系,正常使用没啥问题,但是当tl不用的时候(t1=null),如果Entry中的key和ThreadLocal是强引用的话,这个key就不会被GC清除,那么这个ThreadLocal就产生了内存泄漏,但是如果采用了弱引用就不存在这个问题了,弱引用会自动被GC清除,此时ThreadLocal中的key的泄漏问题就解决了。remove方法虽然弱引用可以解决ThreadLocalMap中key的内存泄漏问题,但是ThreadLocalMap对象中的value在GC的时候是不会被回收的,并且随着GC将key清除之后,value也再无法访问到,如果创建ThreadLocal的线程一直持续运行(比如在线程池中),那么这个Entry对象中的value就有可能一直得不到回收,发生内存泄露。为了解决这个问题,ThreadLocal中提供了一个**remove()**方法,我们应该在ThreadLocal使用完之后主动的调用这个方法,帮助GC可以清除掉整个Entry。下面我们直接看remove方法的核心实现,如下图所示。逻辑很简单,找到目标key之后直接调用clear方法将此Entry和ThreadLocalMap引用设为null,然后调用expungeStaleEntry方法重新计算Entry的布局。至此ThreadLocal的内存泄漏问题才算是解决了。replaceStaleEntry方法还记得在set方法中遍历Entry数组时如果遇到key被垃圾回收器回收的情况ThreadLocal是如何处理的吗?没错就调用了replaceStaleEntry方法,这个方法的作用就是当一个key被GC回收之后,那就需要把过期的值替换,设置成为新值。我们来看一下它的实现:三、ThreadLocalMap剖析首先明确一点,ThreadLocalMap是ThreadLocal的内部类,并且它并没有实现Map接口,而是独立实现一个只可以存放以ThreadLocal为key的Map。下面是ThreadLocal的成员变量:INITIAL_CAPACITY:ThreadLocalMap的初始容量,默认是16table:ThreadLocalMap存放数据的地方size:Entry数组的大小threshold:扩容阈值,默认初始是0,但是在setThreshold方法中设置的值是Entry数组长度的2/3,如下图所示。1、Hash冲突如何解决的?ThreadLocal采用了与HashMap不同的解决hash冲突的方法——开放地址法,即:先根据key的hashcode值确定元素在table数组中的位置(这个位置通过key.threadLocalHashCode&(len-1)获得),如果发现这个位置上已经有其他key值的元素被占用,则利用固定的算法寻找下一个位置。如果在某个位置上没有元素了,那就把新值放到该位置上。显然ThreadLocalMap采用线性探测的方式解决Hash冲突的效率很低,如果有大量不同的ThreadLocal对象放入map中时会发生严重的hash冲突。所以这里建议:每个线程只存一个变量,这样的话所有的线程存放到map中的Key都是相同的ThreadLocal,如果一个线程要保存多个变量,就需要创建多个ThreadLocal,多个ThreadLocal放入Map中时会极大的增加Hash冲突的可能2、ThreadLocalMap采用开放地址法原因ThreadLocal中可以看到一个属性HASH_INCREMENT=0x61c88647,0x61c88647是一个神奇的数字,让哈希码能均匀的分布在2的N次方的数组里,即Entry[]table,关于这个神奇的数字google有很多解析,这里就不重复说了ThreadLocal往往存放的数据量不会特别大(而且key是弱引用又会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也低3、ThreadLocalMap和HashMap比较ThreadLocalMap和HashMap的功能类似,但是实现上却有很大的不同:HshMap的数据结构是数组+链表+红黑树(JDK1.8+)TreadLocalMap的数据结构仅仅是数组HshMap是通过链地址法解决hash冲突的问题TreadLocalMap是通过开放地址法来解决hash冲突的问题HshMap里面的Entry内部类的引用都是强引用TeadLocalMap里面的Entry内部类中的key是弱引用,value是强引用

    LoveIT 2020-03-30
    Java多线程与高并发
  • Java多线程和高并发总结(基础篇)

    Java多线程和高并发总结(基础篇)

    一、进程的概念?进程可以理解为一个应用程序执行的实例(比如在windows下打开Word就启动了一个进程),进程是资源分配的最小单位,每个进程都有自己独立的地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段。进程主要有数据、程序和程序控制块(PCB)组成,其中PCB是系统感知进程存在的唯一标志。二、线程的概念?线程是进程中的一个执行单元,一个进程中可以启动多个线程,并且一个进程中的多个线程可以共享此进程中的所用资源。每个线程都有自己独立的运行时桟和程序计数器,线程是CPU调度的最小单位。三、并发和并行的概念?并发(concurrent):单核CPU下,操作系统通过任务调度器,将CPU的时间片分给不同的线程使用,只是由于CPU的切换速度非常快(Windows下一个最小的时间片是15ms),让用户看上去是同步执行的,实际上还是串性执行的。这种线程轮流使用CPU的方法叫并发。并行(parallel):多个cpu或者多台机器同时处理任务,是真正意义上的同时执行。四、创建和启动线程Java中创建线程的方法本质上只有两种,那就是继承Thread类和实现Runnable接口两种,其余的方式都是他俩的变种。1、继承java.lang.Thread通过继承Thread类来创建并启动多线程的步骤如下:定义Thread类的子类,并重写Thread的run()方法,该run()方法的方法体就代表了线程要完成的任务,因此把run()方法称为线程执行体。创建Thread类的实例及创建线程对象。调用线程对象的satrt()方法来启动该线程。2、实现java.lang.Runnable接口实现Runnable接口来创建并启动多线程的步骤如下。定义Runnable接口的实现类,实现Runnable接口的run()方法,该run()方法的方法体同样是该线程执行体。创建Runnable接口实现类的实例,并以此实例作为Thread的构造方法的参数来创建Thread对象。调用线程对象的start()方法来启动该线程。Java8以前的写法:Java8以后可以用lambda简化代码3、实现java.util.concurrent.Callable<V>接口​在很多的Java书籍或者博客上都写的Java中第三种创建线程的方式是实现jdk1.5开始提供的Callable接口。的确,从形式上看,它确实是一种新的方式,我们只需要实现Callable接口的call()方法,而且call方法还可以有返回值,还可以抛出异常,功能非常牛逼!并且在jdk1.5中提供了Future接口来表示call()方法的返回值,并且提供了一个FutureTask实现类。但是,从本质上看,FutureTask类直接实现了RunnableTask接口,间接实现了Runnable和Future接口。因此,这就相当于还是一个Runnable的实现类,而且打开源码可以发现(在FutureTask的第255行开始)确实是这样:因此,这种实现方式只是使用Runnable的特例,本质还是在使用Runnable实现创建一个新线程。不过话说回来,虽然是特例,但是我们还是有必要掌握如何使用Callable来启动新线程的。创建并启动有返回值的线程的步骤如下。创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值。在创建Callable接口实现类的实例。使用FutureTask类来包装Callable接口对象,该FutureTask对象,封装了该Callable对象的call()方法的返回值。使用FutureTask类对象作为Thread对象的构造函数参数创建并且启动新线程。调用FutureTask对象的get()方法,获得子线程执行结束后的返回值。4、使用线程池这块内容比较多而且非常重要,感兴趣的同学可以参考我的博文:高并发编程之线程池ThreadPoolExecutor详解五、线程的上下文切换(ThreadContextSwitch)多线程编程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在用完CPU时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换会这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。当发生以下情况时,会发生线程上下文切换:线程的CPU时间片用完垃圾回收有更高优先级的线程需要运行线程自己代用了sleep()、yield()、wait()、join()、park()....六、Java中线程的5种状态Java中的线程有5种转态:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)(1)New:当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时仅由JVM为其分配内存,并初始化其成员变量的值(2)Runnable:线程被创建后,其他线程调用了线程的start()方法之后该线程处于这个状态。该状态的线程处于可执行线程池中,就绪状态的线程表示有权力去获取CPU时间片了,CPU时间片就是执行权。当线程拿到CPU时间片后就会马上执行run()方法,这时线程就进入了运行状态。(3)Running:线程获取到CPU时间片后线程执行任务的状态(4)Blocked:阻塞状态是由于当前执行中的线程因为某种原因放弃CPU使用权,暂时停止运行的状态。处于阻塞状态的线程必须再次切换到Runnable才能再次获取到CPU时间片。阻塞的类型可以分为三种:等待阻塞:运行中的线程执行了wait()方法,JVM会把该线程放入等待队列(waitingqueue)同步阻塞:运行中的线程尝试获取一个对象的对象锁时,当发现这把锁正在被其他线程使用,那么JVM就会把该线程放入锁池(lockpool)其他阻塞:运行中的线程调用了sleep()、join()方法或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(5)Dead:当执行中的线程执行完线程任务或执行过程中发生了Error或Exception线程都会死亡。下图更详细的展示了Java中一个线程的生命周期:七、Java中如何停止线程的方法如何停止线程是Java并发面试中的常见问题,这里总结一下。答题思路:停止线程的正确方式是使用中断想停止线程需要停止方,被停止方,被停止方的子方法相互配合扩展到常见的错误停止线程方法:已被废弃的stop/suspend,无法唤醒阻塞线程的volatile标记位方式1、正确的方式:使用interrupt()安全的终止程序(推荐)关于使用interruput()方法来终止线程,最佳的说明文档就是javadoc了。在这个方法的javadoc上说明了在使用interruput()的三种情况:(1)如果当前线程处于阻塞状态:比如调用了sleep()、wait()以及重载方法、join()以及重载方法或者其他操作让当前线程进入到阻塞状态,此时调用interrupt(),那么它的中断状态会被清除为false并且会收到一个InterruptedException异常比如一个线程调用了wait()方法后处于阻塞状态,此时别处调用interrupt()后会立即将线程的中断标记设为“true”,但是由于线程处于阻塞状态,所以该“中断标记”会立即被清除为“false”,同时,会产生一个InterruptedException的异常。(2)如果线程被阻塞在一个Selector选择器中,那么通过interrupt()中断它时,线程的中断标记会被设置为true,并且它会立即从选择操作中返回。(3)线程未处于阻塞状态,那么通过interrupt()中断线程时,它的中断标记会被设置为“true”第一个方法是通过循环不断判断自身是否产生了中断:在上面的代码中,我们在循环条件中不断判断线程本身是否产生了中断,如果产生了中断就不再打印。还有一个方法是通过java内定的机制响应中断:当线程调用sleep(),wait()方法后进入阻塞后,如果线程在阻塞的过程中被中断了,那么线程会捕获或抛出一个中断异常,我们可以根据这个中断异常去控制线程的停止。具体代码如下:2、各方配合才能完美停止在上面的两段代码中已经可以看到,想通过中断停止线程是个需要多方配合。上面已经演示了中断方和被中断方的配合,下面考虑更多的情况:假如要被停止的线程正在执行某个子方法,这个时候该如何处理中断?有两个办法:第一个是把中断传递给父方法,第二个是重新设置当前线程为中断。第一个例子:在子方法中把中断异常上抛给父方法,然后在父方法中处理中断第二个例子:在子方法中捕获中断异常,但是捕获以后当前线程的中断控制位将被清除,父方法执行时将无法感知中断。所以此时在子方法中重新设置中断,这样父方法就可以通过对中断控制位的判断来处理中断3、常见错误停止线程例子这里介绍两种常见的错误,先说比较好理解的一种,也就是开头所说的,在外部直接调用Thread类的stop()方法把运行中的线程停止掉。这种暴力的方法很有可能造成脏数据,就象突然关闭计算机电源,而不是按正常程序关机一样,可能会产生不可预料的结果,不安全主要是:thread.stop()调用之后,创建子线程的线程就会抛出ThreadDeatherror的错误,并且会释放子线程所持有的所有锁。一般任何进行加锁的代码块,都是为了保护数据的一致性,如果在调用thread.stop()后导致了该线程所持有的所有锁的突然释放(不可控制),那么被保护数据就有可能呈现不一致性,其他线程在使用这些被破坏的数据时,有可能导致一些很奇怪的应用程序错误。因此,并不推荐使用stop方法来终止线程。在上面的例子中,我们模拟军队发放武器,规定一个连为一个单位,每个连有10个人。当我们直接从外部通过stop方法停止武器发放后。很有可能某个连队正处于发放武器的过程中,导致部分士兵没有领到武器。这就好比在生产环境中,银行以10笔转账为一个单位进行转账,如果线程在转账的中途被突然停止,那么很可能会造成脏数据。另外一个常见错误就是:通过volatile关键字停止线程。具体来说就是通过volatile关键字定义一个变量,通过判断变量来停止线程。这个方法表面上是没问题的,我们先看这个表面的例子上面的代码可以正常执行,但是这个方法有一个潜在的大漏洞,就是若线程进入了阻塞状态,我们将不能通过修改volatile变量来停止线程,看下面的生产者消费者例子:上面的例子运行后会发现生产线程一直不能停止,因为他处于阻塞状态,当消费者线程退出后,没有任何东西能唤醒生产者线程。这种错误用中断就很好解决:八、等待/通知机制1、什么是等待/通知机制?多个线程之间也可以实现通信,原因就是多个线程共同刚访问同一个变量。但是这种通信机制不是“等待/通知”,两个线程完全是主动地读取一个共享变量。简单的说,等待/通知机制就是一个【线程A】等待,一个【线程B】通知(线程A可以不用再等待了)。比如生产者和消费者模型,消费者等待生产者生产资源,这是等待,生产者生产好资源通知等待的消费者去消费,这是通知。等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在java.lang.Object类中2、等待/通知机制的经典范式(模板)(1)等待方(消费者)需遵循如下原则:获取对象锁如果条件不满足,那么调用对象的wait()方法,被通知后仍然要检查条件条件满足则执行对应逻辑(2)通知方(生产者)需遵循如下原则:获得对象锁改变条件通知所有等待该对象锁的线程3、使用等待/通知机制实现生产者和消费者模型详细的生产者消费者模式的实现可以参考我的另一篇文章Java并发编程实践资源:Resourcs.java生产者:Producer.java消费者:Customer.java九、sleep()与与wait()区别wait()方法定义在Object类中,作用于所有对象;sleep()方法定义在Thread类中,作用于当前线程wait()方法只能在同步块或者同步方法中调用;sleep()可以在任何地方调用最主要的区别:调用wait()方法后,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态;而sleep()方法调用后不会释放锁资源,如果sleep()是在同步上下文中调用的,那么其他线程是无法进入到当前同步块或者同步方法中的十、什么是线程死锁?如何避免死锁?1、什么是线程死锁?多线程或多进程提高了系统的资源利用率以及系统的处理能力,但是这也同样会带来新的问题——死锁。所谓的死锁是指多个线程因为竞争资源而造成的互相等待的僵局,如果没有外力干预,这些进程都将无法向前推进。如下图所示,线程A持有锁1,线程B持有锁2,他们同时都想申请对方的资源,但是有无法获取到,所以这两个线程就会因互相等待而进入死锁状态。2、面试官:你给我写一个死锁执行结果:3、如果避免死锁?在谈如何避免之前先了解一下产生死锁的4个必要条件:互斥条件:该资源任意一个时刻只由一个线程占用。请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。不剥夺条件:线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系这四个条件是产生线程死锁的必要条件,缺一不可,因此避免死锁就可以破坏掉其中一个条件即可。(1)破坏互斥条件这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。(2)破坏请求与保持条件一次性申请所有的资源。(3)破坏不剥夺条件占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。(4)破坏循环等待条件靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。4、使用工具定位程序中的死锁Java提供了两个工具可以分析程序中是否有死锁发生,一个是jps命令配合jstack;另一个是jconsole工具。下面我来介绍一下如何使用这两个工具分析程序中的死锁。(1)使用jps命令配合jstack首先使用jps命令查看运行中的Java线程然后使用jstack线程Id查看具体的线程信息如果发生了死锁还会给出死锁信息以及死锁发生的代码行数,非常方便(2)使用jconsole工具jconsol是一个图形化的检测工具,不仅可以检测线程,还可以分析内存,功能更加强大!!!在cmd命令行中输入jconsole,等待图形化工具启动,启动完成后连接上目标程序后,点击线程--->检测死锁,之后如果程序中发生了死锁就会被列举出来。检测死锁的结果

    LoveIT 2020-03-29
    Java多线程与高并发
  • 深入理解JVM—垃圾回收器(Grabage Collector)进阶篇

    深入理解JVM—垃圾回收器(Grabage Collector)进阶篇

           在上一篇博客深入理解JVM—垃圾回收器(GrabageCollector)基础篇我们了解了Java判定一个对象是否为垃圾的两种算法,以及3种垃圾回收算法。如果说收集算法室是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。接下来我们就一起来了解一下常见的垃圾收集器。1、垃圾收集器在G1垃圾出现之前,JVM是对堆内存进行分代管理的,分为新生代和老年代,按照这样的划分,不同分区的回收器个有3种:1.1新生代垃圾回收器(1)Serial    Serial收集器是最基础、历史最悠久的收集器,曾经(在JDK1.3之前)是HotSpot虚拟机新生代收集器的唯一选择,时至今日,垃圾收集器的不断改进,不断出新,但是Serial依然在我们的垃圾收集器的选项里面。Serial收集器的特点:比较概述垃圾回收算法复制算法使用范围新生代应用Client模式下的默认新生代收集器优点简单高效,在单CPU的环境下,Serial收集器由于没有线程交换的开销,有着很高的垃圾收集效率。缺点垃圾收集过程中需要长时间的暂停所有用户线程,官方把这种现象称为StopTheWorld,对于现在的Java应用长时间的StopTheWorld势必会影响用户的体验。开启Serial收集器:使用Serial收集器需要使用一个JVM参数:-XX:+UserSerialGC(2)ParNew    ParNew收集器是Serial收集器的多线层版本,除了使用多线程进行垃圾收集之外,其余行为与Serial完全一致。在实现上,两者也共用了相当多的代码。他可以配合CMS以及SerialOld这两个老年代收集器工作。ParNew默认开启的垃圾收集线程数与CPU的数量相同,可以使用-XX:ParllelGCThreads参数来限制垃圾收集的线程数。ParNew收集器的特点:比较概述垃圾回收算法复制算法使用范围新年代应用运行在Server模式下的虚拟机中首选的新生代收集器优点在多CPU时,比Serial效率高缺点收集过程暂停所有应用程序线程,单CPU时比Serial效率差。(3)ParallelScavenge(PS)    ParallelScavenge收集器也是一款新生代收集器,它同样是基于复制算法实现的收集器,它是能够并行收集的多线程收集器。ParallelScavenge的诸多特性从表面上看和ParNew非常相似,ParallelScavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而ParallelScavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。吞吐量=运行用户代码时间/运行用户代码时间+运行垃圾收集器时间。比如虚拟机总共运行了100分钟,垃圾收集时间用了1分钟,吞吐量=(100-1)/100=99%。若吞吐量越大,意味着垃圾收集的时间越短,则用户代码可以充分利用CPU资源,尽快完成程序的运算任务。ParallelScavenge收集器的特点:比较概述垃圾回收算法复制算法使用范围新生代应用高吞吐量为目标,即减少垃圾收集时间(就是每次垃圾收集时间短,但是收集次数多),让用户代码获得更长的运行时间;当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互;就是说可以计算完后进行一次长时间的GC。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序优点多线程、可以控制吞吐量(-XX:MaxGCPauseMillis最大垃圾收集停顿时间,-XX:GCTimeRatio吞吐量大小)、GC自适应调节策略(-XX:+UserAdaptiveSizePolicy)缺点垃圾收集过程中需要长时间的暂停所有用户线程,官方把这种现象称为StopTheWorld,对于现在的Java应用长时间的StopTheWorld势必会影响用户的体验。使用ParallelScavenge收集器-XX:+UseParallelGC,在启用ParallelScavenge收集器后会自动开启ParallelOld收集器1.2老年代垃圾回收器(1)SerialOld收集器    SerialOld是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。这个收集器的主要也是供客户端模式下的HotSpot虚拟机使用。SerialOld收集器的特点:比较概述垃圾回收算法标记-整理算法使用范围老年代应用(1)在JDK1.5之前与ParallelScavenge收集器搭配使用(2)作为CMS收集器在发生ConcurrentModeFailure时的后备收集器优点简单高效,在单CPU的环境下,Serial收集器由于没有线程交换的开销,有着很高的垃圾收集效率。缺点垃圾收集过程中需要长时间的暂停所有用户线程,官方把这种现象称为StopTheWorld,对于现在的Java应用长时间的StopTheWorld势必会影响用户的体验。(2)ParallelOld(PO)       ScavengeParallelOld是ParallelScavenge收集器的老年代版本,在JDK1.6开启提供服务,出现它的目的就是和ParallelScavenge配合工作,它们可以在注重吞吐流以及CPU敏感的场合下使用。(在没有ParallelOld之前,ParallelScavenge只能和SerialOld配合工作,SerialOld是一个单线程的收集器,无法发挥服务器下多CPU的性能,因此ParallelScavenge的性能自然大大折扣)ParallelOld收集器的特点:比较概述垃圾回收算法标记-整理算法使用范围老年代应用配合ParllelScavenge应用于在注重吞吐率以及CPU资源的敏感场合优点多线程收集缺点垃圾收集过程中需要长时间的暂停所有用户线程,官方把这种现象称为StopTheWorld,对于现在的Java应用长时间的StopTheWorld势必会影响用户的体验。(3)CMS(ConcurrentMarkSweep)    CMS(ConcurrentMark-Sweep)是一款并发的、使用标记-清除算法的垃圾回收器。对于要求服务器响应速度的应用,这种垃圾回收器非常适合。CMS是用于对tenuredgeneration的回收,也就是年老代的回收,目标是尽量减少应用的暂停时间,减少fullgc发生的几率,利用和应用程序线程并发的垃圾回收线程来标记清除年老代。CMS收集器的特点:比较概述垃圾回收算法标记-清除算法使用范围老年代应用应用程序对停顿比较敏感,并且在应用程序运行的时候可以提供更大的内存和更多的CPU以及系统在运行的时候有相对较多存活时间较长的对象(老年代比较大)会更适合使用CMS优点并发收集、低停顿缺点(1)CMS对CPU资源十分敏感。CMS默认启动的垃圾收集线程数量是**(CPU数量+3)/4**,显然随着CPU的增多处理垃圾的线程也比增多,那么对用户线程的影响就可能变大。(2)CMS无法处理浮动垃圾,可能导致ConcurrenModeFailure而退化为SerialOld单线程垃圾收集器从而引发另一次FullGC,造成程序停顿时间变的更长。(3)CMS使用的垃圾回收算法导致了它会产生内存碎片化的问题,官方为了解决这个问题提供了一个参数:这个参数默认时开启的,另外为了解决在进行内存整理的时候过长时间的停顿,官方提供了一个参数:用于设置执行多少次不压缩FullGC后跟着来一次带压缩的。默认是0,表示每次FullGC后都压进行碎片整理。开启CMS垃圾回收器使用CMS收集器只需要一条JVM参数:-XX:+UseConcMarkSweepGCCMS的工作阶段从上面的图中我们可以看到,CMS来及回收器的工作分为4个阶段:初始标记:会触发STW,并且是单个GC线程工作,主要是标记一下GCROOts能直接关联到的对象,速度很快;在JVM调优的时为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,-XX:+CMSParallelInitialMarkEnabled,同时调大并行标记的线程数,线程数不要超过cpu的核数。Java中GCRoots包含一下几种:(1)虚拟机桟中引用的对象(2)方法区中类静态属性引用的变量(3)方法区中常量引用的对象(4)本地方法栈中JNI的引用的对象并发标记:这一过程是GC线程和Java用户线程并发执行的,遍历初始标记阶段标记出来的存活对象,然后继续递归标记这些对象可达的对象。因为该阶段并发执行的,在运行期间可能发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些DirtyCard的对象,避免扫描整个老年代。重新标记:由于之前并发标记阶段是GC线程和用户线程同时运行的,因此会导致一部分GCRoots引用发生改变了,这一次标记就是用来修正这些改变的。这一过程只有GC线程在执行,因此会产生STW并发清除:这一阶段GC线程和用户线程并发执行,主要是清除那些没有标记的对象并且回收空间;由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。2、G1(Garbage-First)垃圾收集器       G1垃圾收集器也是以关注延迟为目标、服务器端应用的垃圾收集器,被HotSpot团队寄予取代CMS的使命(在JDK9中已经被设置为默认垃圾回收器),也是一个非常具有调优潜力的垃圾收集器。虽然G1也有类似CMS的收集动作:初始标记、并发标记、重新标记、清除、转移回收,并且也以一个串行收集器做担保机制,但单纯地以类似前三种的过程描述显得并不是很妥当。事实上,G1收集与以上三组收集器有很大不同:G1的设计原则是"首先收集尽可能多的垃圾(GarbageFirst)"。因此,G1并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此G1天然就是一种压缩方案(局部压缩);G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要完全独立的survivor(tospace)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都可能随G1的运行在不同代之间前后切换;G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。使用场景同时注重吞吐量和低延时,默认的暂停目标是200ms对于超大堆内存,,会将堆划分为多个大小相等的Region整体上采用标记-整理算法,两个Region(区域)之间采用复制算法相关JVM参数:-XX:+UserG1GC开启G1收集器-XX:G1HeapRegionSize=size设置每个Region的大小-XX:MaxGCPauseMillis=time设置GC暂停的时间

    LoveIT 2020-02-18
    JVM
    JVM
  • 深入理解JVM—垃圾回收器(Grabage Collector)基础篇

    深入理解JVM—垃圾回收器(Grabage Collector)基础篇

    1、如何判断对象可以被回收1.1引用计数法       给对象添加一个引用计数器,每当有一个地方引用该对象的时候此计数器+1;当一个引用失效后计数器-1,当计数器的值减为0了的对象就不在可能被使用了。优点:实现简单,判定效率高。缺点:当对象之间的相互循环引用时,会导致GC失效,从而造成内存泄漏。1.2可达性分析算法以一系列“GCRoots”为起点,从这些对象开始向下搜索,当一个对象到GCRoots没有任何引用链相连接(GCRoots沿着引用链到达不了目标对象)时,GC就会标记这些对象不可用。目前主流的商用程序语言(Java、C#等)在主流的实现中,都是通过可达性分析来判定对象是否存活的。如上图所示,GCRoots到Object1、Object2、Object3、Object4都是可达的(可达意为GCRoots沿着某条路径(引用链)一定可以找到该对象),但是GCRoots到Object5、Object6、Object7没有可达的引用链,因此它们将会被判断为可回收的对象。优点:更加精确和严谨,可以分析出循环数据结构相互引用的情况;缺点:实现比较复杂、需要分析大量数据,消耗大量时间、分析过程需要GC停顿(引用关系不能发生变化),即停顿所有Java执行线程(称为"StopTheWorld",是垃圾回收重点关注的问题)。在Java语言中可以作为GCRoots的对象有以下几种(1)虚拟机桟桟帧中的局部变量表所引用的对象。(2)方法区中类的静态的属性所引用的对象。(3)方法区中常量引用的对象。(4)本地方法桟中本地方法所引用的对象。GCRoots细节当一个对象被GCRoots标记为不可用之后,这个对象并不是非死不可的,要宣告一个对象的死亡还需要进过两个过程:(1)当一个对象通过可达性分析发现它没有与GCRoots相连接的引用链,那他会被第一次标记并且进行第一次筛选,筛选的条件是此对象的finalize()方法是否有必要执行。当对象的finalize方法没有重写或已经被执行过了,那么将不会再执行,那么此对象就再也没有“自救”的机会。(2)如果判断对象有必要执行finalize()方法,那么这个对象将会被放入一个名为F-Queue队列中,并在稍后通过一个由虚拟机自动创建的、低优先级的名为Finalizer的线程去自动调用finalize()方法。finalize方法是一个对象逃脱死亡的最后几机会,并且也只有这一次机会,在调用finalize之后,GC会对F-Queue进行第二次小规模的标记,如果对象在finalize方法中将自己和引用链上的任何一个对象建立关联即可逃过此次GC,否则对象就可以被宣告死亡,接下来就会被当成垃圾回收掉。finalize机制的一些总结(1)如果一个类A重写了finalize()方法,那么每次创建A类的对象的时候都会多创建一个Finlizer对象(java.lang.ref.Finlizer)指向刚刚新建的对象;如果类没有重写finalize()方法,这没有这一步;(2)Finalizer对象内部维护了一个unfinalized链表,每次创建的Finalizer对象都会插入到该链表中;(3)如果类没有实现finalize()方法,那么进行垃圾回收的时候,可以直接从堆内存中释放该对象,效率非常高;(4)如果一个类实现了finalize()方法,进行GC的时候,如果发现某个对象只被Finalizer对象引用,那么会将该Finalizer对象加入到Finalizer类的引用队列中,并从unfinalized链表中删除该节点。这个过程是JVM在GC的时候自动完成的。(5)含有finalize()的对象从内存中释放,至少需要经过两次GC。(6)Finalizer是JVM内部的守护线程,优先级很低。Finalizer线程的职责很明确,就是不停的循环等待F-Queue中新增对象,一旦发现队列中出现了新对象,他会弹出该对象,调用他的finalize()方法,将该引用从Finalizer类中移除,因此下次GC在执行的时候,这个Finalizer实例以及它所引用的对象都被回收了。(7)使用finalize容易导致OOM,因为如果创建对象的速度很快,那么Finalizer线程的回收速度赶不上创建速度,就会导致内存中垃圾越来越多。2、Java中的几种引用#####(1)强引用类似Objectobj=newObject()这样的引用方式就是强引用,只有所有的GCRoots对象都不通过强引用引用该对象的时候,该对象才能被垃圾回收。(2)软引用仅有软引用引用一个对象时,在垃圾回收后系统内存任然不足,将会再次发生GC把这些软引用对象清除,如果回收后内存还是不足,才会抛出内存溢出异常。一个软引用可以使用SoftReference<V>类来实现。软引用可以配合引用队列来释放软引用自生。       软引用可以用于存放一些重要性不是很强又不能随便让清除的对象,比如图片编辑器、视屏编辑器、缓存等。如下实例就可以说明:运行结果及分析:在图中①处系统内存发现内存紧张了,因此发生了一次GC,此时系统内存挪一挪还可以放下一个4MB大小的byte[]对象,但是当第5次申请内存的时候发现系统内存又不用了,我们也看到了,此时对于20M的堆内存已经用了4*4MB了,再加上其他的对象占用一点内存,堆上是真的没有足够的空间分配给第5个4MB大小的byte[]对象了,此时我们软引用的作用就发挥出来了,从图中可以看到最后一次FullGC直接把新生代中对象清空了,并且老年代的内存占用也大幅减少,而清除掉的这些正是前面分配的被软引用的对象(从最终打印的几个null值可以看出),如果没有这种机制,那么前面几个对象是被List所引用的,GC是没有办法清除它们的。使用引用队列释放软引用/弱引用对象自身在上面的例子中验证了软引用第一条特点:被软引用引用的对象会在系统内存紧张的时候被回收,但是SoftReference这个对象并没有回收,这也会对系统内存造成浪费,因此下面我们就使用引用队列来把这个对象也清除了。运行结果:(3)弱引用仅有弱引用引用一个对象时,在垃圾回收的时候GC一定会回收该对象。一个弱引用可以使用WeakReference<V>类来实现。弱引用可以配合引用队列来释放弱引用自生JDK中的解决ThreadLocal内存泄漏问题就用到了弱引用,详细的分析见对ThreadLocal的理解?ThreadLocal如何解决内存泄漏问题?(4)虚引用它是最弱的是一种引用关系,必须配合引用队列来使用,主要配合ByteBuffer使用,被引用的对象回收的时候,会将虚引用入队,有ReferenceHandler线程调用虚引用相关方法释放直接内存。最后通过一幅图来说明一下JVM回收机制究竟如何区别对待各种引用类型:3、垃圾回收算法3.1标记-清除算法(Mark-Sweep)标记-清除算法分为两个阶段:标记和清除。首先使用上面介绍的可达性分析算法标记出所有需要回收的对象。在标记完成后统一回收所有标记的对象,实质是将回收的对象的起始和结束地址记下来,下次在给新的对象分配内存的时候如果大小合适就覆盖这片内存。标记清除算法比较简单粗暴,实现也比较简单。但是它留下了两个比较麻烦的问题:效率问题,标记和清除两个过程效率都不高空间问题,标记清除过后会产生大量的内存碎片,太多的内存碎片会在需要给大对象分配内存的时候,无法找到足够的连续空间而不得不触发另一次GC。3.2复制算法(Copying)复制算法把一块可用的空间分为大小均等的两块区域,每次只使用其中的一块,当这块内存快用完时同样是需要先做标记,然后将有用对独向复制到另一个区域,之后把已使用过的内存一次清理掉。复制算法解决了标记清理算法带来的内存碎片问题,并且实现简单,运行高效;但是这种的算法的代价是“浪费”了一半的内存可用空间。       在现代的商用JVM堆内存的新生代采用了复制算法,在SunHotSpot虚拟机中新生代分为Eden空间和两块较小的Survivor空间,每次使用Eden空间和一个Survivor空间,每次GC的时候都会将Eden和Survivor的From区中的有效对象进行标记,一同复制到Survivor的To区。然后彻底清除原来的Eden区和From区的内存对象。与此同时To区就是下一次回收的From区。这一过程中新生代的内存只有90%再被使用,总会有10%的空间”浪费“。3.3标记-整理算法(Mark-Compact)复制算法需要一块额外的内存空间,用于存放幸存的内存对象。这无疑造成了内存的浪费。因此在原有的标记清除算法的基础上,提出了优化方案。也就是标记到的可用对象整体向一侧移动,然后直接清除掉可用对象边界以外的内存。这样既解决了内存碎片的问题。又不需要原有的空间换时间的硬件浪费。由于老年代中的幸存对象较多,而且对象内存占用较大。这就使得一旦出现内存回收,需要被回收的对象并不多,碎片也就相对的比较少。所以不需要太多的复制和移动步骤。因此这种方法常常被应用到老年代中。标记整理算法的缺点:标记整理算法由于需要不断的移动对象到另外一侧,而这种不断的移动其实是非常不适合杂而多的小内存对象的。每次的移动和计算都是非常复杂的过程。因此在使用场景上,就注定限制了标记整理算法的使用不太适合频繁创建和回收对象的内存中。3.4分代垃圾回收机制(GenerationalCollection)       这种算法就是将内存以代的形式划分,然后针对情况分别使用性价比最高的算法进行处理。在Java中,一般将堆分为老年代和新生代。新创建的对象往往被放置在新生代中。而经过不断的回收,逐渐存活下来的对象被安置到了老年代中。越新的对象越可能被回收,越老的对象反而会存活的越久。因此针对这两种场景,新生代和老年代也会分别采用前文所述的两种算法进行清理。4、内存分配与回收策略4.1对象优先在Eden分配4.2大对象直接进入老年代4.3长期存活的对象将被晋升到老年代4.4动态对象年龄判定4.5空间分配担保(HandlePromotion)       通过上面的介绍我们了解到新生代采用的垃圾回收算法主要是复制算法,然而我们并不能保证上一次新生代收集下来的存活对象都可以在另一个Survivor空间中可以装得下,当另外一块Survivor空间的内存不够的时候,我们就要依赖其他内存进行分配担保(HandlePromotion)。分配担保中如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入到老年代。内存担保的过程具体如下:每次发生MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么这次MinorGC就是安全的。;如果条件不成立,那么将会查看HandlePromotionFailure设置值是否允许内存担保失败,如果允许,那么将会检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均水平大小,如果大于,那么将会冒着风险尝试进行MinorGC;如果小于或者JVM参数设置HandlePromotionFailure不允许内存担保失败,那么就会不执MinorGC而执行FullGC。

    LoveIT 2020-02-17
    JVM
    JVM
  • 深入理解JVM内存结构—堆(Heap)

    深入理解JVM内存结构—堆(Heap)

    1、堆(Heap)概述Java堆(JavaHeap)是JVM所管理的最大的一块内存空间。Java堆是被所有线程共享的一块内存区域,在JVM启动的时候创建,堆的唯一目的就是存放对象实例的,Java中几乎所有的对象实例和数组都在堆上分配内存,但是随着JIT编译器的发展与逃逸分析技术的成熟,桟上分配、标量替换等优化技术使得对象可以在桟帧中直接分配。Java堆的容量可以是固定大小的,也可以随着程序执行的需求动态扩展,并在不需要过多空间时自动收缩。Java堆所使用的内存亦不需要保证是连续的。Heap用来存储数对象实例的,例如:首先JVM会在堆中分配出Student对象存储的内存区域,并将地址返回,然后再在JVMStack中的局部表量表中创建Student对象的引用用来存放Heap分配的Student地址。之后就可以在程序中使用栈中的引用对象来访问堆中的数组或对象。当使用完对象后,我们不必显式的管理堆内存释放工作,堆内存的释放会由GC(垃圾收集器)自动完成。图1对象的分配和访问Java堆是垃圾回收器工作的主要区域,因此堆也被称为“GC堆”(GarbageCollectedHeap)。从垃圾回收的角度来看,由于早期的收集器基本采用分代收集算法,所以Java堆可以细分为:新生代(Young/NewGeneration)和老年代(Old/TenuredGeneration老年代)。新生对象放置在新生代中,新生代由Eden空间、SurvivorFrom空间、SurvivorTo空间组成;老年代用于存放程序中经过指定次数垃圾回收后还存活的对象。图2堆内存分代模型(1)Young/NewGeneration新生代程序中新建的对象都将分配到新生代中,新生代又由Eden(伊甸园)与两块Survivor(幸存者)Space构成。Eden与SurvivorSpace的空间大小比例默认为8:1,即当Young/NewGeneration区域的空间大小总数为10M时,Eden的空间大小为8M,两块SurvivorSpace则各分配1M,这个比例可以通过-XX:SurvivorRatio参数来修改。Young/NewGeneration的大小则可以通过-Xmn参数来指定。●Eden:刚刚新建的对象将会被放置到Eden中,这个名称寓意着对象们可以在其中快乐自由的生活。●Survivor空间:幸存者区域是新生代与老年代的缓冲区域,两块幸存者区域在逻辑上分别为s0与s1。当触发MinorGC后将Eden区中仍然存活的对象移动到S0中去(FromEdenTos0)。这样Eden就会被清空可以分配给新的对象。当再一次触发MinorGC后,S0和Eden中存活的对象被移动到S1中(Froms0Tos1),S0即被清空。在同一时刻,只有Eden和一个SurvivorSpace同时被操作。所以s0与s1两块Survivor区同时会至少有一个为空闲的,这点从下面的图中可以看出。       当每次对象从Eden复制到SurvivorSpace或者从SurvivorSpace之间复制,计数器会自动增加其值。默认情况下如果复制发生超过15次,JVM就会停止复制并把他们移到老年代中去。如果一个对象不能在Eden中被创建,它会直接被创建在老年代中。●新生代GC(MinorGC):指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,通常很多的对象都活不过一次GC,所以MinorGC非常频繁,一般回收速度也比较快。●MinorGC清理过程(图中红色区域为垃圾):图3MinorGC发生之前图4MinorGC发生之后注意:1.图中的"From"与"To"只是逻辑关系而不是Survivor空间的名称,也就是说谁装着对象谁就是"From"。2.一个对象在幸存者区被移动/复制的次数决定了它是否会被移动到堆中。(2)old/TenuredGeneration老年代老年代用于存放程序中经过几次垃圾回收后还存活的对象,例如缓存的对象等,老年代所占用的内存大小即为-Xmx与-Xmn两个参数之差。堆是JVM中所有线程共享的,因此在其上进行对象内存的分配均需要进行加锁,这也导致了new对象的开销是比较大的,鉴于这样的原因,HotspotJVM为了提升对象内存分配的效率,对于所创建的线程都会在Eden区分配一块独立的空间,这块空间称为TLAB(ThreadLocalAllocationBuffer),其大小由JVM根据运行的情况计算而得,也可以使用jvm参数调整。在TLAB上分配对象时不需要加锁,因此JVM在给线程的对象分配内存时会尽量的在TLAB上分配,在这种情况下JVM中分配对象内存的性能和C语言基本是一样高效的,但如果对象过大的话则仍然是直接争抢Eden区进行分配,TLAB是在Eden去划分出来的一块区域,一般不会太大,因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效,但这种方法同时也带来了两个问题,一是空间的浪费,二是对象内存的回收上仍然没法做到像Stack那么高效,同时也会增加回收时的资源的消耗,可通过在启动参数上增加-XX:+PrintTLAB来查看TLAB这块的使用情况。●老年代GC(MajorGC/FullGC):指发生在老年代的GC,但它并不只是在老年代回收空间,通常会伴随至少一次MinorGC(但也并非绝对,在ParallelScavenge收集器的收集策略里则可选择直接进行MajorGC)。MajorGC的速度一般会比MinorGC慢10倍以上。虚拟机给每个对象的对象头可以记录对象的年龄(4bit)。如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。(3)逃逸分析逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法内定义时候并且只在方法中使用了,就认为没有发生逃逸;当一个对象在方法中定义之后,它被外部方法所调用,就认为发生了逃逸。例如作为调用参数传递到其他参数中。发生了逃逸:StringBuilder对象会作参数传递给其他方法,因此这里发生了逃逸,不会在桟上分配空间。没发生逃逸:在方法中分配的内存没有传递给其他方法,自在本方法中使用了,没有发生逃逸,因此可以在桟上直接给StringBuilder分配内存。逃逸分析的优化1)桟上分配一个方法的中的引用如果没有发生逃逸,则会直接在桟帧上分配内存,这样可以提高对象的分配效率并且可以减少GC的次数。桟上分配的好处:不需要GC介入去回收这些对象,出栈即释放资源,可以提高性能。原理:由于我们GC每次回收对象的时候,都会触发StopTheWorld(STW),这时候所有用户线程都停止了,然后我们的GC线程工作去进行垃圾回收,如果对象频繁创建在我们的堆中,也就意味这我们也要频繁的暂停所有线程,这对于用户无非是非常影响体验的,栈上分配就是为了减少垃圾回收的次数2)标量替换JVM中的原始数据类型(int,long等)都不能在进一步分解,他们就可以成为标量。相对的,如果一个数据可以继续分解,那么他成为聚合量,java中最典型的聚合量就是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个这个对象是可以分解的,那么程序真正执行的时候可能不创建这个对象,而改为直接创建它的若干个被这个方法能够使用到的成员变量来代替。拆散后的变量便可以被单独的分析与优化,可以分别分配在栈帧或者寄存器上,原来的对象就不需要整体被分配在堆中。标量替换的JVM参数如下:开启标量替换:-XX:+EliminateAllocations关闭标量替换:-XX:-EliminateAllocations显示标量替换详情:-XX:+PrintEliminateAllocations3)锁消除线程同步的代价是相当高的,同步带来的后果是降低了并发性和程序性能。逃逸分析以判断某个对象是否始终只被一个线程访问,如果只被一个线程访问,那么该对象的同步操作就可以转化为没有同步的操作,这样可以大大提高并发性能锁消除的JVM参数如下:开启锁消除:-XX:+EliminateLocks关闭锁消除:-XX:-EliminateLocks锁消除在JDK8中都是默认开启的,并且锁消除都要建立在逃逸分析的基础上。2、JVM中给对象分配内存的过程通过上的学习我们可以大致总结出一个对象被创建过程中内存申请过程:对象分配过程1)依据逃逸分析,判断是否可以在桟上分配内存?如果没有发生逃逸,那么就会在桟帧上直接分配内存。线程销毁或方法调用结束之后,直接出栈,没有GC介于回收垃圾。优点:可以在函数调用结束后自行销毁对象,不需要垃圾回收器的介入,有效避免垃圾回收带来的负面影响栈上分配速度快,提高系统性能局限性:栈空间小,对于大对象无法实现栈上分配2)判断是否是大对象?大对象这个概念比较模糊,那么多大的对象是大对象呢?其实这个值可以使用jvm参数指定的:大对象到底多大:-XX:PreTenureSizeThreshold=n(仅适用于DefNew/ParNew新生代垃圾回收器)。参考链接G1回收器的大对象判断,则依据Region的大小(-XX:G1HeapRegionSize)来判断,如果对象大于Region50%以上,就判断为大对象HumongousObject。3)判断是否可以在TLAB中分配内存?由于对象一般分配在堆上,而堆是线程共用的,因此可能会有多个线程在堆上申请空间,而每一次的对象分配都必须线程同步,会使分配的效率下降。考虑到对象分配几乎是Java中最常用的操作,因此JVM使用了TLAB这样的线程专有区域来避免多线程冲突,提高对象分配的效率。局限性:TLAB空间一般不会太大(占用eden区),所以大对象无法进行TLAB分配,只能直接分配到堆Heap上。4)在Eden区分配内存(1)JVM会试图为新对象在Eden区中初始化一块内存区域。(2)当Eden空间足够时,内存申请结束。否则进入第三步。(3)JVM试图释放在Eden中所有不活跃的对象(执行一次MinorGC),释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区。(4)Survivor区被用来作为新生代与老年代的缓冲区域,当老年代空间足够时,Survivor区的对象会被移到老年代,否则会被保留在Survivor区。(5)当老年代空间不够时,JVM会在老年代进行0级的完全垃圾收集(MajorGC/FullGC)。(6)MajorGC/FullGC后,若Survivor及老年代仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,JVM此时就会抛出内存不足的异常。3、Java堆相关调优参数总结JVM的调优参数,我这里首推官方文档。另外我这里总结了几个常用的Java堆有关的调优参数,大家可以参考学习。(1)-Xmssize:设置初始堆大小,默认为物理内存的1/64(<1GB);默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制(2)-Xmxsize:设置最大堆大小,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制。等价的参数:-XX:MaxHeapSize=size(3)-Xmnsize:设置新生代的内存空间大小,即Eden+2个survivorspace。在保证堆大小不变的情况下,增大新生代后,将会减小老生代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。等价的参数:-XX:NewSize=size(4)-XX:SurvivorRatio:设置新生代中Eden区域与Survivor区域的容量比值,默认值为8。两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10。(5)-XX:NewRatio=ratio:设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)。设置为4,则年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5(6)-XX:MaxTenuringThreshold=threshold:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。对于年老代比较多的应用,可以提高效率。如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象在年轻代的存活时间,增加在年轻代即被回收的概论。(7)-Xsssize:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小默认为1M,以前每个线程堆栈大小默认为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。(8)-XX:TLABSize=size:设置TLAB的初始大小,将它设置为0表示让JVM自动选择初始大小。(9)-XX:+PrintGCDetails:打印GC信息(10)-XX:+PrintFlagsInitial:打印所有参数的默认值(11)-XX:+PrintFlagsFinal:打印所有参数当前值(12)-XX:+HeapDumpOnOutOfMemoryError:让虚拟机在发生内存溢出时Dump出当前的内存堆转储快照,以便分析用。默认情况下,堆内存快照会保存在JVM的启动目录下名为java_pid.hprof的文件里(在这里就是JVM进程的进程号)。也可以通过设置-XX:HeapDumpPath=来改变默认的堆内存快照生成路径,可以是相对或者绝对路径。(13)-XX:OnOutOfMemoryError:当内存溢发生时,我们甚至可以可以执行一些指令,比如发个E-mail通知管理员或者执行一些清理工作。通过-XX:OnOutOfMemoryError这个参数我们可以做到这一点,这个参数可以接受一串指令和它们的参数。在下面的例子中,当内存溢出错误发生的时候,我们会将堆内存快照写到/tmp/app/heapdump.hprof文件并且在JVM的运行目录执行脚本发送邮件:(14)-XX:+HandlePromotionFailure:jvm默认启用空间分配担保。年轻代每次MinorGC之前都会计算一下老年代剩余的可用空间,如果大于,那么此次MoniorGC是安全的;如果剩余空间小于年轻代所有对象的大小总和(包括垃圾对象),那么就会看这个参数有没有开启(jdk1.8以后这个参数默认开启),如果有这个参数,就会看看老年代的可用空间是否大于之前MoniorGC时候老年代的平均大小,如果大于,那就尝试进行一次MoniorGC(有风险);否者进行FullGC,如果FGC之后还放不下新生代的对象,那就抛出OOM(15)-XX:+DoEscapeAnalysis:启用逃逸分析(默认打开)(16)-XX:+EliminateAllocations:标量替换(默认打开)

    LoveIT 2020-02-16
    JVM
    JVM
  • 深入理解JVM— Java对象的创建过程、对象内存布局、对象的引用方式详解

    深入理解JVM— Java对象的创建过程、对象内存布局、对象的引用方式详解

    一、对象的创建过程(1)判断类是否加载。检查常量池中是否可以定位到指定类的符号引用,并且检查这个符号引用所代表的类时候已经被加载、链接和初始化过。如果可以定位到符号引用,并且已经被加载过:进入第2步如果没法定位到符号引用或没有被加载过:执行相应的类加载过程。(2)分配内存。(指针碰撞:Serial、ParNew/空闲列表:CMS)。(3)初始化零值。为对象中的实例字段赋零值(不是给静态属性赋零值,静态属性在类加载的时候就初始化完成了,详情参考深入理解JVM—虚拟机类加载机制)。(4)设置对象头(ObjectHeader)。如设置此对象是哪个类的实例、如何才能知道类的元数据信息、对象的hashcode、对象的GC分代年龄......。(5)执行类的构造方法(站在JVM的角度是执行<init>方法)。初始化类的实例字段。这5步执行后一个真正可用的对象才算完全产生出来。二、对象的内存布局在HotSpot虚拟机中对象在内存中的存储布局可以分为:对象头、实例数据和对齐填充3部分。图3.1对象的内存布局(1)对象头(ObjectHeader)对象头在HotSpot虚拟机中被分为了两部分,一部分官方称为MarkWord;另一部分是类型指针。如果对象是一个Java数组,那在对象头中还有一块用于记录数组长度的数据。图3.2对象头第一部分MarkWord用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、对象分代年龄等信息。MarkWord被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据自己的状态复用自己的存储空间在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么MarkWord的32bit空间中的25bit用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit最后三位001标识对象处于无锁状态。如下表所示:第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。在不开启类指针压缩(-XX:+UseCompressedClassPointers)的情况下是8字节。压缩后变为4字节,默认压缩。通过命令:java-XX:+PrintCommandLineFlags-version查看classPointer是否开启压缩参数解释:-XX:InitialHeapSize=192266304起始堆大小-XX:MaxHeapSize=3076260864最大堆大小建议:一般建议InitialHeapSize和MaxHeapSize设置为一样-XX:+UseCompressedClassPointers压缩类指针,一般java是64位的操作系统,那么指针的长度即64位,即8字节,开启此命令后(默认是开启的),classPointer压缩为4字节-XX:+UseCompressedOops压缩普通对象指针,不开启占用8个字节,这里开启了普通对象引用占用4字节(2)实例数据(InstanceData)       实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。       这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。原生类型的内存占:byteshortcharintlongdoublefloatboolean1字节2字节2字节4字节8字节8字节4字节1字节引用类型的内存占用32位系统普通引用类型指针统一占4字节64位系统,普通引用类型指针占用的大小和UseCopmerssedOops有关:开启UseCompressedOops时(指针压缩),占用4字节,否则是8字节(3)对齐填充(Padding)       由于HotSpotVM的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,即:Java对象的大小必须是8字节的整数倍,而对象头的大小正好是8字节的整数倍,所以当对象的实例数据没有对齐的时候,就需要对齐填充来补全。因此对齐填充并不是必须存在的,也没有特殊的含义,它仅仅起到了占位符的作用。(4)对象头内存占用分析在32位JVM下,存放MarkWord空间的大小是4字节,KlassWord指针的空间大小是4字节,因此头部就是8字节,如果是数组就需要再加4字节表示数组的长度,如下表所示:在64位JVM未开启指针压缩下,头部的MarkWord和KlassWord都是8字节,这样一来头部就占用16字节,如下表所示:在64位JVM开启指针压缩下,头部的MarkWord没有变化还是8字节,但是KlassWord压缩成了4字节,也就是64位系统下如果开启指针压缩对象头部最少为12字节,如下表所示:针对对象头的各部分大小,还有一道经典的面试题:Objectobj=newObject()这句代码需要占用多大的内存?答:32位JVM下是12bytes(8+4),64位JVM下开启压缩类指针(classpointer)、开启普通对象压缩占用20bytes(8+4+4+4);开启压缩类指针(classpointer)、未开启普通对象压缩占用24bytes(8+4+4+8);都不开启占用24bytes(8+8+0+8)。分析如下:32位操作系统下:一个对象由对象头(MarkWord+Klasspointer)、实例数据、对齐填充构成。在32位系统下,MarkWord4bytes,KlassPoninter4bytes,因此对象头就占有至少8bit,之后Object类中没有实例数据,因此这一段为0,之后按照虚拟机规范对齐填充为8bytes整数倍,最终Object对象占用8字节,然后引用Objecto在桟中,32位系统中占用4bytes,因此总共占用12bytes64位操作系统下:MarkWord8bytes,Klasspointer4\8bytes,对象头总计12\16bytes,实例数据为0,之后再填充到8整数倍最终为16bytes,然后引用Objecto在桟中,这就是个普通对象指针,64位系统中占用大小与UseComperssedOops参数有关,未开启占用8字节,开启占用4字节,因此总共占用20\24bytes三、对象的访问定位目前主流的对象访问方式有使用句柄和直接指针两种方式。#####(1)句柄访问       在Java堆内存中划分一块内存专门用来作为句柄池,JVM桟的局部变量表的reference存储对象的句柄地址,而句柄中保存了对象实例数据与类型数据的具体地址,如下图所示:       使用句柄方式最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。(2)直接指针访问       此时reference直接存储对象在堆中的地址,不再需要句柄池,这样做最大的好处是提高了性能,因为它节省了一次指针定位的时间开销。然而这也是HotSpotVM所选择的对象访问方式。

    LoveIT 2020-02-15
    JVM
    JVM
  • 深入理解JVM—字符串常量池StringTable

    深入理解JVM—字符串常量池StringTable

           首先我们来看一道关于字符串的面试题,请大家先不要直接上机运行,自己先在脑子里运行一下这段程序,如果你可以很清晰的得到所有输出,那么恭喜你!这篇文章你就不要在浪费时间再看了;如果你在某一步还有不清楚的,那么这篇文章将会一网打尽所有你对String常量池的疑虑。运行结果:1、StringTable概述       StringTable又可以称为StringPool,字符串常量池,在JDK1.7以前字符串常量池是方法区中的运行时常量池的一部分,JDK1.7及以后JVM为了提高性能和减少对方法区内存的开销把字符串常量池被移到了堆内存中。字符串常量池的作用大致是:每当我们创建字符串的时候,JVM首先会检查字符串常量池,如果该字符串已经存在于字符串常量池中,那么就直接返回它在常量池中的直接引用;否则,就会实例化该字符串并且将其放到常量池中,由于String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串。2、StringTable的特性概述字面量字符串会在编译阶段直接添加到常量池中常量池中的字符创建仅仅是符号,只有在第一次用到的时候才会创建对象(字符串的延迟加载性)利用串池的机制,可以避免字符串的重复创建字符串变量的拼接原理是通过StringBuilder实现的(JDK1.8)字符串常量的拼接原理是编译阶段的优化可以使用String类的intern()方法,主动将串池中还没有的字符串对象放入串池JDK1.7及以后会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有就放入,最后会返回串池中对象的直接引用。JDK1.7以前会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有则把此对象复制一份再放入串池,最后会返回串池中对象的直接引用3、String在JVM中的解析(JDK1.7+)3.1字符串的两种创建方式在Java中我们创建字符串的方式一般有两种形式:直接字面量和newString()创建对象,例如:这两行代码在内存解析的过程如下图:       从图中可以看出,str1使用字面量创建字符串,在编译期的时候就对常量池进行判断是否存在该字符串,如果存在则不创建直接返回对象的引用;如果不存在,则先在常量池中创建该字符串实例再返回实例的引用给str1。       再来看看str2,str2使用关键词new创建字符串,JVM会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就不再在字符串常量池创建该字符串对象,而直接堆中复制该对象的副本,然后将堆中对象的地址赋值给引用str2;如果字符串不存在常量池中,就会实例化该字符串并且将其放到常量池中,然后在堆中复制该对象的副本,然后将堆中对象的地址赋值给引用str2。3.2字符串拼接       字符拼接也有两种形式:一种全是字面量(常量)拼接,这种拼接方式形成的字符串会在编译期就被优化直接形成目标字符串并存到字符串常量池中;另一种含有变量的字符串拼接,这种拼接方式的底层原理是利用StringBuilder拼接好目标字符串后在转换为新的字符串对象(并不会主动放进字符串常量池中)。(1)常量拼接:编译阶段就可以确定例如:Stringstr1="Hello,"+"java";       前面说到过,String字符串的不可变性我们可以十分肯定常量池中一定不存在两个相同的字符串,根据这个特点,在编译阶段javac编译器就会把各个待拼接的常量直接组合为目标字符串并放入字符串常量池中,这一过程同样需要判断是否已经存在该字符串。(2)变量拼接:编译阶段无法确定,只有在运行后才可以知道结果例如:Stringstr1="hello,";Stringstr2="java";//str1、str2对应的字符串在编译阶段就被放进了字符串常量池找中Stringstr3=str1+str2;//实质:newStringBuilder().append("hello,").append("java").toString();==>newString("hello,java");上面三行代码在JVM中的解析过程如下图所示:       当使用“+”连接字符串变量时在运行期才能确定的,连接的本质是通过StringBuilder拼接之后再toString()新建一个新的String对象存储在堆中。下面是这三行代码编译后使用javap工具反编译出来的字节码:       字节码中0~5行的作用就是从常量池中加载字符串常量并将其引用存储在局部变量表中。第6行字节码创建了一个StringBuilder实例,第10行调用了StringBuilder调用了StringBuilder的无参构造器初始化Stringuilder实例,接着13~17行就是从局部变量表中取出刚才存储的两个字符串引用的值调并用StringBuider类的append()方法拼接字符串;最后拼接完成后调用了StringBuilder的toString()方法产生了一个新的String对象存储到堆中,之后在局部变量表中str3指向了堆中这个String对象的地址(24行的astore_3这条指令)。(3)更一般的字符串拼接—常量和变量混合拼接将(2)中的例子改为如下直接看编译后的字节码:通过字节码和(2)的规律我们不难得到内存模型如下:       其本质和(1)(2)还是一样的,只要是常量,在编译期间就会确定下来并被加入常量池中,而且这里还遵循贪心原则,就是编译器会尽可能多的把可以确定下来的常量构成一个新串加入到字符串常量池中,之后对于变量就只能在运行的时候处理了,拼接的时候还是需要借助StringBuilder的append()方法;并且最后依然只会返回在堆中字符串对象的地址。3.3String.intern()方法解析intern()方法在JDK1.7以前和JDK1.7及以后的实现略微有所差别:●JDK1.7以前会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有则把此对象复制一份再放入串池,最后会返回串池中对象的直接引用。●JDK1.7及以后会将这个字符串对象尝试放入串池,如果串池中有这个字符串则不会放入,如果没有就存储堆中这个字符串的引用,也就是字符串常量池中存储的是指向堆里的对象,最后还是会返回字符串常量池中该字符串的直接引用。验证下面我们看一个案例:JDK6的执行结果为:falseJDK7和JDK8的执行结果为:trueJDK6-的内存模型如下:       我们都知道JDK6中的常量池是放在永久代(方法区)的,永久代和Java堆是两个完全分开的区域。当调用str1.intern()后,JVM首先会检查字符串常量池中是否存在该字符串,如果没有就把该字符串复制一份然后添加到字符串常量池中,最后返回指向该常量的引用。如果之后str2在调用intern()方法,那么就会直接返回常量池中“abc”的引用。因此上面代码s3和s3.intern不相等的原因就是因为它们两个任然不是同一个对象。JDK7/8+的内存模型如下:       JDK7/8+中,字符串常量池已经被转移至Java堆中,开发人员也对intern方法做了一些修改。因为字符串常量池和new的对象都存于Java堆中,为了优化性能和减少内存开销,当调用intern方法时,如果常量池中已经存在该字符串,则返回池中字符串;否则直接存储堆中的引用,也就是字符串常量池中存储的是指向堆里的对象。所以结果为true。4、StringTable垃圾回收       字符串常量池是有垃圾回收的,无论是在JDK1.6以前还是在JDK1.7以后对于字符串常量池都是由垃圾回收机制的。接下来我们就在JDK1.8环境下通过一段代码验证一下StringTable的垃圾回收。运行后我们来看看控制台打印信息       我们看到没有发生GC,并且当前键值的数量是1773个。之后我们往常量池中不断添加字符串,主要就观察有没有发生GC以及StringTablestatistics就好了运行后我们再来看看控制台打印信息       这一次我们看到总共进行了3次GC,并且打印的当前字符串常量池的键值对数量远远小于添加的字符串数量10,0000个,说明对字符串常量池进行了垃圾回收。5、StringTable性能调优5.1调节-XX:StringTableSize=size       StringTable的底层数据结构是hash表,hash表的基本组成就是数组和链表,hash表中的每个数组单元我们叫做桶(bucket),桶的数量越多,hash冲突的几率就越低,但同时耗费的空间就越多。因此对于StringTable的性能调优额基本原理就是在减少hash碰撞(调整StringTable桶的个数)以及和内存消耗之间找到系统运行的平衡点。下面以一个例子来说明:在使用默认值的情况下上面的代码的运行结果如下:       通过上图可以看到使用JVM默认对StringTable设置的桶的个数(60013)时,执行向字符串常量池中添加40万条字符串花费了442ms,接下来我尝试把StringTable的桶的个数调大了些(200000),运行结果如下:       运行结果和我的预期相符合,执行同样的代码花费的时间减少了约0.1s,并且多次运行的数值都是在这个值附近,说明增加StringTable桶的个数对那种系统中有大量字符串时的性能提高是有帮助的。为了进一步验证,我又把StringTable桶的个数调节的很小(2000),运行结果(如下图)再次验证了通过调节StringTable桶的个数是有助于当系统中有大量字符串时的性能提升的。5.2使用intern()方法当系统中需要处理大量字符串并且这些字符串可能会存在重复的问题,那么这时可以使用intern()方法将这些字符串添加到常量池中,以减少对堆内存的消耗。

    LoveIT 2020-02-14
    JVM
    JVM
  • 深入理解JVM—方法区

    深入理解JVM—方法区

    1、方法区概述       方法区(MethodArea)和Java堆内存一样是线程共享的一块内存区域,它被主要用来存储已经被虚拟机加载的类信息(字段、方法、构造器的字节码)、常量、静态变量、JIT编译后的代码等等(说的再简单直白点就是用来存储每个已经记载的类的结构信息)。       然而方法区只是JVM规范中定义的一个概念,在具体的虚拟机产品中对于方法区有不同的实现,以Java目前商用最广泛的HotSpot虚拟机来说,在JDK1.7及以前方法区的实现叫做永久代(PermenentGeneration),在JDK1.8以后又变成了元空间(Metaspace)。他们其实是一个东西,只不过是不同版本下的不同实现而已。图1方法区2、方法区中存储内容概述2.1类信息存储JVM中类的类型信息,对每一个加载的类型,JVM必须在方法区中存储以下的类型信息●这个类型的全限定名;●这个类型直接父类的全限定名(除非这个类型是interface或者是java.lang.Object,这两种情况下都没有父类);●这个类型的修饰符(public,abstract,final的某个子集);类型名称在java类文件和jvm中都以完整有效名出现。在java源代码中,完整有效名由类的所属包名称加一个".",再加上类名组成。例如,类Object的所属包为java.lang,那它的完整名称为java.lang.Object,但在类文件里,所有的"."都被斜杠“/”代替,就成为java/lang/Object。完整有效名在方法区中的表示根据不同的实现而不同。2.2域(属性)信息(程序中的一个范围)JVM必须在方法区中保存类的所有属性的相关信息以及属性的声明顺序,具体需要保持的相关信息包括:●属性名;●属性类型;●属性修饰符(public,private,protected,static,finalvolatile,transient的某个子集)2.3方法信息JVM必须保存所有方法的如下信息,同样和属性信息一样也要保存方法声明顺序,方法的相关信息●方法名;●方法的返回类型(或void);●方法参数的数量和类型(有序的);●方法的修饰符(public,private,protected,static,final,synchronized,native,abstract的一个子集)除了abstract和native方法外,其他方法还有保存方法的字节码(bytecodes)操作数栈和方法栈帧的局部变量区的大小。2.4类变量●类变量被类的所有实例共享并且在内存中只有一份,即使没有类实例时你也可以访问它。这些变量只与类相关,所以在方法区中,它们成为类数据在逻辑上的一部分。在JVM使用一个类之前,它必须在方法区中为每个non-final类变量分配空间(在类加载的准阶段)。●常量(被final修饰的类变量)的处理方法则不同,每个常量都会在常量池中有一个拷贝。non-final类变量被存储在声明它的类信息内。注意!●java类中的成员变量有静态和非静态,静态成员变量是共享数据,在共享区,也叫方法区中;●非静态成员变量在堆内存中,作用于每个实例(在堆上创建对象时即给其分配区域)●局部变量在栈内存内,Java虚拟机栈为每一个被调用方法都分配一个栈帧,用于存放方法中的局部变量,对象的引用类型都会在此分配内存,引用指向的对象是在堆上。2.5运行时常量池(RuntimeConstantPool)       运行时常量池(RuntimeConstantPool)是方法区的一部分。.class字节码文件中除了有类的版本、字段、方法、接口等描述类的信息外,还有一项信息是常量池(ConstantPoolTable),用于存放编译器生成的各种字面量(文本字符串、声明为final的常量值等)和符号引用(类和接口的全限定名、字段的名称和描述符、方法的名称和描述符),这部分内容将在类加载后进入方法区的运行时常量池中存放。图2使用javap命令查看字节码中的常量池       运行时常量池相对于常量池的一个重要特征是具有动态性。这是什么意思呢,就是当你的java源文件文件一旦编译形成class文件后,你的常量池就确定了,而运行时常量池在运行期间也可能有新的常量放入池中(如String类的intern()方法)。3、方法区的OOM3.1永久代的OOM和元空间的OOM       由于方法区只是JVM规范中的一个概念,具体的实现在不同的虚拟机上有很多不同,即使是同一个虚拟机的不同版本对于方法区的实现也会有许多差异,接下来我们就以HotSpot为例来演示一下方法区的OOM(OutOfMemoryError)。       JDK7及以前在HotSpot虚拟机中方法区的具体实现是永久代,此时的永久代真实划分的内存还是在运行时数据内的,当空间不够时将抛出错误java.lang.OutOfMemoryError:PermGenspace;JDK8及以后方法区的具体实现变成了元空间,元空间的真实内存划分是在物理内存(NativeMemory)上的,解决了JDK7以前方法区容易发生OOM的问题,但是这并不代表元空间就不会发生OOM了,极端情况下当物理内存满了还是会导致抛出错误java.lang.OutOfMemoryError:Metaspace。3.2对方法区内存大小的调节JVM参数依然是先看管方文档,调节永久代或元空间大小的JVM参数如下:图3.1永久代内存大小调节参数●-XX:MaxPermSize=size是用来设置永久代最大内存的,当超出这个内存限制将会抛出java.lang.OutOfMemoryError:PermGenspace。●-XX:PermSize=size是用来设置永久代触发垃圾回收的最小内存的。注意!这些参数只有在JDK8以前的Java版本中有用。图3.2元空间内存大小调节参数●-XX:MaxMetaspaceSize=size是用来设置元空间最大内存大小的,从官网的说明可以看到,这片区域默认是没有大小限制的,当设置了大小并且使用超过了限制或者没有设置大小使用导致物理内存被占满都将会抛出java.lang.OutOfMemoryError:Metaspace。●-XX:MetaspaceSize=size是用来设置元空间触发垃圾回收的最小内存的。注意!这些参数只有在JDK8及以后的Java版本中有用。3.3方法区OOM再现分别在JDK6和JDK8环境下运行下面使用CGLib实现的一个代理操作:导入代码后还需要导入CGLib的依赖:在JDK8环境下运行上面的代码,发生如下图所示报错:在JDK6环境下运行上面代码,发生如下图所示报错:对于这个例子值得特别注意的是,我们在这个例子中模拟的场景并非纯粹是一个实验,这样的应用经常会出现在实际应用中:当前的很多主流框架,如Spring和Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存,此时就需要我们根据实际情况通过JVM参数调节方法区空间的大小从而解决问题了。

    LoveIT 2020-02-13
    JVM
    JVM
  • 深入理解JVM—Java虚拟机桟

    深入理解JVM—Java虚拟机桟

    1、虚拟机桟概述由于跨平台性的设计,JVM的指令架构是基于桟的结构来设计的,这么做的优点:一是具有了跨平台性,其次使得指令集更小,编译器更容易实现,但缺点也很明显:实现同样的功能需要更多的指令和性能下降。栈是运行时的单位,堆是存储的单位栈解决的程序运行问题,即程序如何执行,或者说如何处理数据;堆解决的是数据存储问题,即就是数据如何放、放哪儿在java中一个线程就会相应有一个线程栈与之对应,因为不同的线程执行逻辑有所不同,因此需要一个独立的线程栈。堆是所有线程共享的。栈因为是运行单位。因此里面存储的是和当前线程相关的数据。包括局部变量、程序运行状态、方法返回值等;而堆只负责存储对象信息。堆中存什么,栈中存什么?堆中存的是对象,栈中存的是基本数据类型和堆中对象的引用,一个对象的大小不可以估计,或者说是可以动态变化的,但是在栈中,一个对象只对应了一个4byte引用对象,从某种意义上说,是由基本类型组成的。可以把一个对象看作为一棵树,对象的属性如果还是对象,则还是一颗树(即非叶子节点),基本类型则为树的叶子节点。程序参数传递时,被传递的值本身都是不能进行修改的,但是,如果这个值是一个非叶子节点(即一个对象引用),则可以修改这个节点下面的所有内容。堆和栈中,栈是程序运行最根本的东西。程序运行可以没有堆,但是不能没有栈。而堆是为栈进行数据存储服务,说白了堆就是一块共享的内存。不过,正是因为堆和栈的分离的思想,才使得Java的垃圾回收成为可能。1.1Java虚拟机桟(JavaVirtualMachineStacks)的特点(1)桟内存线程私有的,它的生命周期与线程相同(即随着线程的创建而创建,随着线程的销毁而销毁)(2)虚拟机桟描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个桟帧用于存储局部变量(LocalVariables)、操作数桟(OperandStack)、动态链接(DynamicLinking)、方法返回地址(ReturnAddress)等信息。新创建的桟帧会被保存到Java虚拟机栈的栈顶,方法执行完毕后自动将此桟帧出栈。一般我们把虚拟机桟栈顶的桟帧称作当前方法。图1.1Java虚拟机桟栈帧(3)虚拟机桟没有GC,但是Java虚拟机规范中,对此区域定义了两种异常情况:如果线程请求的桟深度大于虚拟机允许的深度,将抛出StackOverflowError异常如果虚拟机的实现中允许虚拟机桟动态扩展,当需要扩展但是内存不够的时候将会抛出OutOfMemoryError异常1.2Java虚拟机桟相关参数调优首先学习JVM调优我们还是应该主要参考官方文档。打开官方文档后选择MainToolstoCreateandBuildApplications=>java,之后在页面使用快捷键Ctrl+F搜索-Xss就可以看到关于参数-Xss的说明和用法。图1.2.1Java虚拟机桟调优参数-Xss根据官网的说明,-Xss就是用来设置线程桟(虚拟机桟)的大小的。在参数后面加上k/K表示KB,m/M表示MB,g/G表示GB。并且还说明了不同平台上的虚拟机桟默认的大小,Linux、macOS、OracleSolaris是1024KB,Windows上默认值受虚拟内存的影响。示例:写一个简单的测试代码验证一下这个-Xss参数对Java虚拟机桟的调节效果,测试代码如下:在没有设置桟大小使用默认值时,在我电脑上发生StackOverflowError异常时打印的count值为9544图1.2.2调参之前报错信息在IDEA中设置虚拟机桟的大小具体操作看图中的演示:图1.2.3选择Run-->EditConfigurations...图1.2.4设置VMoptions参数为-Xss256m设置好后再次运行刚刚的程序,发现过了好久才报StackOverflowError异常,而且最后打印的count值为3893229,截图如下:图1.2.5调参之后报错信息2、Java虚拟机桟的存储单位2.1Java虚拟机桟桟中存储的什么?每个线程都有自己的桟,Java虚拟机桟都是以桟帧(StackFrame)为基本单位存在。一个线程上每个被调用的方法都有一个桟帧。桟帧是一个内存区块,是一个数据集,维护着方法执行过程中的各种数据信息。2.2Java虚拟机桟的运行原理不同线程中所包含的桟帧是不允许存在相互引用的,即不可能在一个线程的某个桟帧中引用另外一个线程中某个桟帧。(线程可以共享同一个进程中的共享数据,但是线程内的数据无法共享)如果当方法调用了其他方法,方法返回之际,当前桟帧会传回此方法的执行结果给前一个桟帧,接着JVM会丢弃当前桟帧,使得前一个桟帧重新成为当前桟帧。Java的方法有两种返回方式:一种是方法正常执行结束返回,使用return命令;另一种是抛出异常,没有捕获导致虚拟机挂掉。两种返回方式都会导致桟帧被弹出。2.3桟帧的内部结构详解每个桟帧的结构包括:局部变量表(LovalVariables)、操作数桟(OperandStack)、动态链接(DynamicLinking)、方法返回地址(ReturnAddress)和一些其他的一些附加信息图2.3.1JVM桟帧结构2.3.1局部变量表(LovalVariables)1、局部变量表也被称为局部变量数组或本地变量表。2、局部变量表本质是一个数组,主要用于存储方法参数和定义在方法体内的局部变量的值,可以存放的数据类型有boolean、byte、char、short、int、float、long、double、对象引用(reference)和returnAddress类型。(基本数据类型存储的是数值,引用类型存储的是引用)图2.3.2局部变量表存储结构3、局部变量表中32位以内的类型只占用一个Slot(包括returnAddress),64位的类型(long和double)占用两个Slot。byte、short、char在存储的时候会被转换为int,boolean也会被转换为int,0表示false,非0表示true4、由于局部变量表是建立在线程的桟帧上的,是线程私有的,因此不存在线程安全问题。5、局部变量表所需要的容量大小是在编译期间就可以确定下来的,并保存在方法的Code属性的locals数据项中,并且在方法运行期间是不会改变局部变量表的大小的。图2.3.3局部变量表大小6、在固定的虚拟机桟内存大小下,可以调用的方法的个数取决于桟帧的大小。对于一个方法而言,它的参数和局部变量越多,使得局部变量表膨胀,它的桟帧也会变大,以满足方法调用所需传递的信息增大的需求。7、局部变量表中的变量只有在当前方法调用中才有效,当方法调用结束后随着方法桟帧的销毁而销毁。8、JVM会为局部变量表中的每个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量表的值。图2.3.4局部变量表分析9、如果需要访问局部变量表中的一个64bit局部变量值时,使用的时候只需要使用其起始Slot索引即可。10、如果当前桟帧是由构造方法或者实例方法创建的,那么方法所属对象引用this将会作为局部变量放在局部变量表的index=0的Slot处,其余变量从Slot的index=1开始按定义的顺序存储。图2.3.5实例方法的第一个Slot存放的一定是this引用11、为了节约桟帧的空间,局部变量表的Slot是可以重复利用的。图2.3.6局部变量表的Slot可以复用2.3.2操作数桟(OperandStack)操作数桟就是JVM执行引擎的一个工作区,本质是一个由数组构成的桟,当一个方法开始执行的时候,一个新的操作数桟就会被创建。操作数桟初始是空的,主要用于保存计算过程的中间结果,同时作为计算过程变量的临时存储空间。每一个操作数桟所需要的最大桟深度会在编译器就确定下来了,这个值保存在方法Code属性的max_stack选项中。桟中的任何一个元素可以是任意Java类型。32位的类型占用一个桟单位深度,64位的类型占用两个桟单位深度操作数桟并非采用访问索引的方式来进行数据访问,而是只能通过入栈和出栈操作完成一次数据访问。基本执行逻辑主要是对变量值的入栈、出栈、运算、入栈…例如将两个int类型的局部变量相加再将结果保存至第三个局部变量:使用JDK自带的javap反汇编器,可以查看java编译器为我们生成的字节码。通过它,我们可以对照源代码和字节码,从而了解很多编译器内部的工作。反汇编得到上面方法的字节码如下:操作数栈中元素的类型必须与字节码指令的序列严格匹配,例如上面的iadd操作时,不能出现iadd操作需要的值第一个为long第二个为float的情况。2.3.3动态链接(DynamicLinking)在Java源文件被编译到直接码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池。而我们都知道每一个桟帧内部包含一个结构—动态链接,它就是指向运行时常量池中该桟帧所属方法的引用。每个桟帧持有一个引用就是为了将符号引用转换为调用方法的直接引用。可以用下面的图形象的解释:图2.3.7动态链接图解2.3.4方法返回地址(ReturnAddress)       当一个方法开始执行后,只有两种方式可以退出这个方法:       第一种方式是执行引擎遇到方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(NormalMethodInvocationCompletion)。       另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(AbruptMethodInvocationCompletion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。       无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为方法返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常表来确定的,栈帧中一般不会保存这部分信息。       方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。3、方法调用3.1虚方法和非虚方法       首先应该明确的是方法的调用不等于方法的执行,方法的调用阶段唯一的任务就是确定被调用的方法的版本,暂时还没有涉及到方法内部的具体运行过程。通过JVM类加载有关内容的学习,我们知道了在类加载的解析阶段会将class文件中的一部分符号引用转化为直接引用,可以这样做的前提是方法在编译的时候就能确定下来。       在Java中符合“编译期可知,运行期不可变”的方法我们称作非虚方法,主要包括静态方法、私有方法、构造方法、父类方法以及被final修饰的方法。除此而外的方法都叫虚方法。与之对应的,在JVM规范中提供了5个有关方法调用的字节码指令,具体如下:invokestatic:调用静态方法,解析阶段可以确定唯一版本invokespecial:调用实例构造器<init>方法,私有方法和父类方法,也是解析阶段可以确定唯一版本invokevirtual:调用虚方法和final修饰的方法(但是final修饰的方法不是虚方法)invokeinterface:调用接口方法invokedynamic:动态解析出需要调用的方法,然后执行invokestatic、invokespecial、invokevirtual、invokeinterface这4条方法调用字节码指令是伴随着Sun的第一款Java虚拟机问世以来就有的,直到JDK7才新增了invokedynamic指令,这条新增的指令是Java实现“动态类型语言”支持的改进之一,也就是为JDK8的Lamba表达式技术而准备的。它与前面4个指令的最大的区别是由前面4个指令调用的方法分派逻辑是固化到JVM内部的,而invokedynamic指令调用的方法分派逻辑完全是由程序员来控制的。3.2静态分派解析调用的过程一定是静态过程,在编译期间就可以完全确定,在类加载的解析阶段就会把涉及的符号引用全部转换为可以确定的直接引用。而分派调用则可分为静态分派和动态分派,具体的有静态单分派、静态多分派、动态单分派、动态多分派这4种组合。下面我们一起来看一下JVM中的方法分派是如何进行的。首先我们来看一段代码:运行结果:结论这段代码对于学过Java的同学应该不难看出运行结果,主要考察的是对重载概念的理解。但你有想过为什么两次都执行了参数类型为Human的重载吗?在说原因之前我们需要知道两个概念:以Humanman=newMan();这条语句来说,我们把Human称为静态类型或外观类型,Man我们称为实际类型。静态类型和实际类型在编译的时候都可发生变化,但是静态类型在编译期最终是可知的;而实际类型要在运行后才能确定。因为在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main方法的两条invokevirtual指令参数中。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的。重载方法的匹配优先级规律首先还是看一段代码:运行上面这段代码,会打印出charobj,这很好理解,a是一个char类型的数据,自然会寻找参数为char的重载方法,如果注释掉这个方法,那么运行结果又会变成intobj,这是由于a的Unicode值为97,进一步的会依次转型为long->float->double。最终把double形参的方法也注释掉后,会打印出Characterobj,发生了自动装箱,a被包装成了封装类型java.lang.Character,继续注释掉Character形参方法后,我们看到编辑器报错了此时匹配上了两个方法:sayHello(Serializableobj)和sayHello(Comparableobj)为什么会匹配上Serializable和Comparable呢?这个不难解释,我们打开Character类一看就知道了,Character实现了Serializable和Comparable这两个接口至于会同时匹配上两个实现接口类型的重载方法,那是因为它两的优先级是一样的,这时就需要我们程序员通过强制类型转换显式的指定要调用那个方法了。最后会匹配变长参数的方法sayHello(char...obj)。因此,从这个案例中我们可以总结出Java中方法重载的优先级匹配规则:优先匹配基本数据类型(char->int->long->float->double),其次匹配它的包装类型,如果没有包装类型但是有包装类型的父类,那么将在继承关系中从下往上开始搜索,越接近上层的优先级约定,优先级最低的是变长参数重载。3.2动态分派3.2.1Java语言方法重写的本质首先我们还是看一段代码:运行结果:运行结果不会出乎人的意料,对于习惯了面向对象思维的Java程序员会觉得这个结果理所当然。但是还是那个问题,为啥会这样?虚拟机是如何知道要调用哪个方法的?我们使用javap命令输出字节码来看看:图3.2.1main方法字节码0~15行的作用是创建对象、分配空间,并把对象的引用保存在局部变量表中。之后16~21行是事情本质的关键,我们看到字节码中调用方法使用了invokevirtual指令,原因就需要从incokevirtual指令的多态查找说起:找到操作数栈顶的第一个元素所指向的对象的实例类型C如果在C类型中找到与常量池中描述符合简单名称都相符的方法,则进行访问权限检查,如果通过就返回这个方法的直接引用,查找结束;如果没有通过,则返回java.lang.IllegalAccessError否者,继续按照继承树从下往上对C的各个父类进行第二步的搜索和检查和验证。如果始终没有找到合适方法,就抛出java.lang.AbstractMethodError异常。所以,由于incokevirtual指令执行的第一步工作就是确定实际类型,所以两次调用中incokevirtual指令都把常量池中的类方法符号引用解析到了不同的直接引用上,而这也就是我们常说的“编译时看左边,运行时看右边”这句话或者Java中方法重写的本质。3.2.2虚方法表通过上面的学习我们知道了方法重写的原理,那么由于动态分派是非常频繁的操作,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用了在类的方法区建立一个虚方法表(VirtualMethodTable,vtable)与之对应的是在invokeinteface执行的时候也会用到接口方法表(InterfaceMethodTable,itable),使用虚方法表的索引来代替元数据查找以提高性能。方法表一般会在类加载的链接阶段完成初始化,准备了类的实例变量之后,虚拟机也会把该类的得到表初始化完毕。

    LoveIT 2020-02-12
    JVM
    JVM
  • 深入理解JVM—程序计数器

    深入理解JVM—程序计数器

    1、JVM内存模型概述Java虚拟机(JVM)在Java程序运行的过程中,会将它所管理的内存划分为若干个不同的数据区域,这些区域有的随着JVM的启动而创建,有的随着用户线程的启动和结束而建立和销毁。一个基本的JVM运行时内存模型如下所示:图1JVM运行时数据区上图是展示JDK8及以后的虚拟机规范对JVM运行时内存的划分。在JVM的运行时数据区中Java虚拟机桟、本地方法桟和程序计数器是每个线程私有的,同时堆区和方法区(即图中展示的元数据区和代码缓存)是线程共享的内存。也就是说堆和方法区在一个JVM中各自只有一份,它们是随着JVM的启动而创建的,但是Java虚拟机桟、本地方法桟和程序计数器是线程私有的,每个线程一份,多个线程就可以随线程的启动创建多份,它们的关系可以用下面的图来表示:图2JVM运行时数据区2、程序计数器(PC)2.1什么是程序计数器程序计数器(ProgramCounterRegister)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。                              ------摘自《深入理解JAVA虚拟机》2.2程序计数器的特点1)线程私有,随着线程的启动而创建,并且随着线程的销毁而销毁(即生命周期和线程的生命周期一样)。2)执行Java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址;当执行Native方法的时候。计数器的值为空(Undefined)。3)程序计数器内存区域是Java虚拟机规范中唯一没有规定OOM(OutOfMemoryError)的区域。2.3关于程序计数器的几个常见问题(1)使用PC存储字节码指令地址有什么用呢/为什么要使用PC来记录当前线程的执行地址呢?因为Java虚拟机的多线程是通过切换线程并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令,因此当CPU不停的切换各个线程,下次切换回来后就需要知道接着从哪个地方开始继续执行。使用PC就可以记录下切换前的下一条字节码指令的地址,下次切换回来后直接跳转到PC所记录的地址接着执行即可。(2)PC为什么要设定成为线程私有的?在Java的多线程环境下,多个线程如果像共享堆内存一样共享一个PC寄存器,那么前一个线程刚保存的下一条字节码指令地址数据就会被下一个线程覆盖,最终还是和没有使用PC是一样的效果。因此为了能够精确的记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程分配一个PC寄存器,毕竟PC的体积也很小。(3)为什么执行Native方法的时候PC的值是Undefined?因为native方法是Java通过JNI直接调用本地C/C++库(可以近似的认为native方法相当于C/C++暴露给Java的一个接口,Java通过调用这个接口从而调用到C/C++方法),由于该方法是通过C/C++而不是Java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由C/C++语言决定的,而不是由JVM决定的。(4)为什么PC不会产生OOM?程序计数器保存的是当前执行的字节码的偏移地址(也就是之前说的行号,其实那不是行号,是指令的偏移地址,只是为了好理解,才说是行号的),当执行到下一条指令的时候,改变的只是程序计数器中保存的地址,并不需要申请新的内存来保存新的指令地址;因此,永远都不可能内存溢出的。

    LoveIT 2020-02-11
    JVM
    JVM
  • 深入理解JVM—虚拟机类加载机制

    深入理解JVM—虚拟机类加载机制

    1、JVM内存结构概述JVM是Java技术的核心,因为任何Java程序最终都需要运行在JVM上。构成JVM的主要三分部分有:类加载子系统、运行时数据区和执行引擎。他们各自发挥着各自的本领,构建起强大的JVM。JVM的具体组成如下图所示:图1JVM整体结构示意(详)图看完这个图,我想大家对于JVM应该会有一个基本的认识,最起码知道了JVM最重要的三大组成部分的位置以及他们内部的大致结构,并且这幅图还间接展示了一个被Java前端编译器编译后生成的class字节码文件被执行的过程,那么我们学习JVM也就可以按照字节码文件执行的流程来学习,这样下来结合原理/结构图我们一定会对JVM有更深刻的认识。因此本片博客我们就来了解一下类加载子系统。2、类加载的时机2.1类的生命周期类从被加载到虚拟机内存中开始,到卸载出内存为止,他的整个生命周期包括:加载(Loading)、验证(Verify)、准备(Prepare)、解析(Resolve)、初始化(Initialization)、使用(Using)和**卸载(Unloading)**7个阶段。其中验证、准备和解析可以合起来统称为链接(Linking)。各个阶段的发生顺序:图2.1类的生命周期其中,加载、验证、准备、初始化和卸载这5个阶段的顺序必须按照图示的顺序按部就班的开始,而解析阶段不一定:它在某些情况下可以在初始化阶段之后在开始,这是为了支持Java的动态绑定特性。2.2类的加载时机Java虚拟机中并没有明确规定类的加载时机,这个不同的虚拟机产品会有不同的实现。但是Java虚拟机规范中对于类的初始化阶段有明确的规定,当出现以下5种情况时必须立即对类进行初始化(间接说明了类的5种加载的时机):当使用new关键字实例化对象、读取或设置一个类的静态字段、调用一个类的静态方法的时候使用java.lang.reflect包的方法对类进行反射调用的时候,如果类还没有初始化过,则需要初始化当初始化一个类的时候如果发现他的父类还没有初始化,那么需要先初始化父类当虚拟机启动的时候,用户指定的执行主类(含有main方法的那个类)会优先初始化这个类当使用动态语言支持时如果一个java.lang.invoke.MethodHandle实例最后的解析结果为RET_getStatic、RET_putStatic、RET_invokeStatic的方法句柄,并且这个方法句柄所在的类没有进行初始化,则需要先初始化。这5种场景中的行为称为对一个类的主动引用,除此之外,其他的所有引用类的方法都不会触发初始化,被称为被动引用。3、类的加载过程图3.1类的加载流程接下来我们来详细学习一下JVM中类加载的全过程,也就是:加载、验证、准备、解析和初始化各个阶段中JVM类加载子系统都干了什么。3.1加载(Loading)在加载阶段,JVM完成下面3件事:通过一个类的全限定名来获取定义此类的二进制字节流将字节流所代表的静态存储结构转化为方法区的运行时数据结构在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问接口总结一下,加载阶段JVM的工作就是:将类.class字节码文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区中的数据结构其中.class字节码文件的获取途径有以下(但不限这几种)方法:从本地系统中直接加载通过网络获取。典型的应用场景:Applet从zip、jar、war、ear等格式的压缩包中获取运行时计算生成,使用最多的是:动态代理技术有其他文件生成,典型场景:JSP从专用的数据库中提取.class文件从加密文件中获取,是典型的防class文件被反编译的保护措施3.2链接(Linking)链接阶段具体有三个过程:验证、准备和解析。验证阶段:验证是链接的第一步,这一步的作用是为了确保class文件中字节流包含的信息符合虚拟机的要求,并且不会危害虚拟机的自生安全。       验证大致上会完成下面4个阶段的检查动作:文件格式验证、元数据验证、字节码验证和符号引用验证。如果输入的字节流不符合class文件格式的约定,JVM就会抛出一个java.lang.VerifyError异常或其子类异常。验证阶段的具体4个动作的作用:文件格式验证:验证的第一步,作用是验证字节流是否符合class文件格式的规范,并且能被当前版本的虚拟机处理,具体的验证点包括:是否以魔数oxCAFEBABE开头,主次版本号是否在当前JVM处理的范围......元数据验证:第二阶段的验证,目的是对字节码描述的信息进行语义分析,以保证器描述的信息符合Java语言规范。字节码验证:第三个阶段的验证,主要目的是通过数据流和控制流分析,确定程序语义是否合法、是否符合逻辑符号引用验证:最后一个阶段的验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化加载链接的第三个阶段—解析的时候发生,这次验证主要目的就是确保解析动作可以正常的执行最后需要说明一点:虚拟机的类加载机制中,验证阶段绝对是非常重要的一个阶段,但是它并不是必要的阶段。如果我们的程序(无论是我们自己写的还是第三方的)都已经被反复使用和验证的情况下,那么在真正运行的时候就可以考虑使用-Xverify:none来关闭大部分的类验证措施,以缩短类加载的时间,毕竟时间就是金钱!!!准备阶段:准备阶段是JVM正式为类变量分配内存并为类变量设置初始值的阶段,这些变量所使用的内存将在方法区中进行分配。不过要清楚几点是:(1)这个阶段给类变量设置的初始值并不是变量后面有程序员指定的值,而是统一设置为零值。正真指定的值这时是存放在类构造器<clinit>()方法中,例如下面的例子:publicstaticinta=100;//在准备阶段变量a会在方法区中分得内存并被赋初值为0,之后又会在初始化阶段初始化为100(2)即被static修饰同时又被final修饰的常量由于在编译的时候就被分配值了,准备阶段只会显示的初始化,也即准备阶段不会管这些常量的。(3)这里只会为一个类中的类变量分配内存并初始化零值,实例变量将会在对象实例化的时候随对象一起分>配在Java堆中。解析阶段:解析阶段是JVM将常量池的符号引用转换为直接引用的过程。解析操作主要针对的类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定7类符号限定引用进行。3.3初始化(Initialization)初始化阶段是类加载过程的最后一个阶段,到了这一步才正真开始执行类中定义的Java程序代码。初始化阶段就是系统给类变量赋指定值并且执行静态代码块的阶段,或者说初始化阶段是执行类构造器<clinit>()方法的过程。关于<clinit>()方法我们需要明确下面几点:<clinit>()方法是由javac编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并而来,不需要人为定义。<clinit>()方法虽然叫类构造器,但它与类的构造函数(类的构造函数在虚拟机视角下是<init>()方法)不同,他不需要显示的调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。<clinit>()方法不是必须的,如果一个类中即没有静态语句块,也没有类变量,那么编译器就可以不为这个类生成<clinit>()方法。虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步。如果有多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他的线程都会阻塞。4、类加载器从JVM的角度来讲,只存在两种不同的类加载器:引导类加载器(BootstrapClassLoader)和用户自定义类加载器。之所以这样划分是因为引导类加载器是使用C++实现的,它是虚拟机的一部分,而其他的加载器(比如扩展类加载器、应用类加载器......)都是使用Java语言实现的,独立于虚拟机外部,并且都直接或间接的继承自java.lang.ClassLoader这个抽象类。但是从Java开发人员的角度来看,JVM的类加载器可以细分为以下几种:4.1启动类加载器(BootstrapClassLoader)1)启动类加载器使用C++语言实现,它就是JVM的组成部分2)它用来加载Java的核心类库($JAVA_HOME/jre/lib/rt.jar、resoures.jar,sun.boot.class.path路径下的内容),用于提供JVM自身需要的类(大致就是以java、javax、sun开头的类库)3)由于是使用C++实现的,因此它不继承自java.lang.Classloader,也没有父加载器,并且启动类加载器无法被Java程序直接引用,如果尝试获取启动类加载器,那么一定返回的是null4.2扩展类加载器(ExtensionClassLoader)由Java语言实现,具体的实现在sun.misc.Launcher$ExtClassLoader这个内部类中,他派生与ClassLoader,主要负责加载$JAVA_HOME/jre/lib/ext目录中的类库,或者被java.ext.dirs系统变量所指定的路径中的所有类库。4.3应用类加载器(ApplicationClassLoder)由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中getSystemClassLaoder()方法的返回值,因此也称其为系统类加载器。它一般负责加载用户路径(ClassPath)上的类库,我们自己写的类一般情况下就是通过这个类加载器加载的。下图时ClassLoader、ExtClassLoader、AppClassloader之间在语言层面的继承关系图4.1双亲委派模型示意图4.4自定义类加载器除了上面Java官方提供的是三种类型的类加载器之外,我们还可以自己定义类加载器,方法很简单,继承java.lang.ClassLoader,然后重写findClass()方法就可以了。自定义类加载器CustomClassLoader:继承ClassLoader,重写findClass()方法,在findClass方法中使用文件IO将需要加载的类读取到byte数组中就OK了,之后交给defineClass方法处理就可以了。被加载的Car类:测试代码:5、双亲委派机制图5.1双亲委派模型示意图5.1双亲委派机制工作原理1)如果一个类加载器收到了类加载的请求,他不会自己立即去加载,而是把这个加载请求委托给父级加载器执行加载请求;2)如果一个父级加载器还存在父级加载器,则进一步向上委托,依次递归,请求最终会传达到最顶层的启动类加载器;3)如果父级加载器可以完成加载任务,就成功返回,倘若父级加载器无法完成加载任务,则它的子级类加载器才会尝试自己加载,如果还是不行在给子级加载器的子级加载器去加载,这就是双亲委派机制。需要指出的是:1.双亲委派模型示意图所展示的不是几种类加载器的继承关系,而是他们在加载一个类的时候的委托关系(优先级关系),而这些类本质上也并不存在继承关系,示意图中所展示的只是一种层级(阶级)关系2.一个类如果所有的类加载器都加载失败,那么系统就会抛出ClassNotFoundException异常验证双亲委派机制:1)自定义类:java.lang.String,并在其中给个main方法看看程序能不能正确的执行之后运行,控制台报错:报错原因分析:当程序已启动扫描到我们的类的全类名是:java.lang.String,根据上面的学习我们知道原本的String应该在java的核心类库rt.jar中(并且是由启动类加载器来加载),于是类加载器就会在rt.jar包下找到java.lang.String类并在类中寻找mian()方法,但是原本的String类没有main()方法,因此会报错找不到main()方法。这么做的好处既保证了一个类不会被重复加载,并且对于这个例子更重要的想说明的是它保证了核心类库不会被非法篡改。2)自定义类:java.lang.Hello,并在其中给个main方法看看程序能不能正确的执行之后运行,控制台报错:报错原因分析:基本原理还是和上面的一样,全类名以java开头的的类一定会由启动类加载器来进行加载,但是在扫描了启动类加载器所管辖的范围没有发现这个类,于是报错。双亲委派模型源码分析:双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在java.lang.ClassLoader的loadClass()中,相关代码如下所示。5.2双亲委派机制的好处(优势)1)可以避免一个类被重复加载2)可以保护程序安全,尤其是核心API不会遭到随意篡改

    LoveIT 2020-02-10
    JVM
    JVM
  • 深入理解JVM—走进Java虚拟机

    深入理解JVM—走进Java虚拟机

    1、JDK,JRE,JVM之间的关系仅从传统意义上来看,Sun定义的Java技术体系包括:Java程序设计语言、各种平台上的JVM、Class文件格式、JavaAPI类库、来自商业机构和开源社区的第三方Java类库。JDK全程为JavaSEDevelopmentKit(Java开发工具),是用于支持Java程序开发的最小环境,提供了编译和运行Java程序所需的各种资源和工具,包括:Java程序设计语言,JavaAPI类库,JVM。JRE全称为Javaruntimeenvironment(Java运行环境),是支持Java程序运行的最小环境,包括:JavaAPI类库中的SEAPI子集,JVM。JVM是运行Java程序的核心虚拟机。他们的关系可以用下图表示:图1Java体系结构2、Java的发展史Java之父:詹姆斯·高斯林1991年,由JamesGosling博士领导的绿色计划中产生了Java语言的前生Oak(橡树),用于嵌入式系统,没有成功;1995年互联网发展,改名为Java,开始火爆,提出Writeonce,Runanywhere的原则;1996年1月发布JDK1.0,jvm为SunClassicVM;1996年5月首届JavaOne大会;1997年2月JDK1.1(内部类、反射、jdbc、javabean、rmi);1998年JDK1.2发布。同时Sun发布了JSP/Servlet、EJB规范,以及将Java分成了J2SE、J2EE和J2ME。2000年5月JDK1.3发布,JavaHotspotVM正式发布,成为Java默认的虚拟机;2002年2月JDK1.4Struts、Hibernate、Spring、正则表达式、NIO、日志、Xml解析器;2004年9月JDK1.5(tiger)自动装箱拆箱泛型注解枚举增强for可变参数Spring2.X;2006年JDK6JavaSeJavaEEJavaME提供脚本语言支持支持http服务器api;2009年Oralcel以74亿美元收购Sun,获得了Java商标和最具价值的HotSpot虚拟机。此时,Oracle拥有市场占有率最高的两款虚拟机HotSpot和JRockit,并计划在未来对他们进行整合:HotRockit2011年JDK7发布。在JDK1.7u4中正式启用了新的垃圾回收器G1。2014年Java8发布,Lambda表达式、函数式接口、方法引用、默认方法、Stream;2017年Java9发布,加入模块化并将G1设置为默认GC,替代CMS。2018年Java11发布,LTS版本的JDK,发布了革命性的ZGC。2019年JDK12发布,加入RedHat领导开发的ShenandoahGC.3、Java虚拟机发展史SunClassicVM1996年随着JDK1.0的发布,Java使用了世界上第一款商用虚拟机,只能使用纯解释器(没有JITJustintime编译器)的方法来执行Java代码,在JDK1.4被废弃。ExactVMExactMemoryManagement准确式内存管理;编译器和解释器混合工作以及两级即时编译器。HotSpotVMHotSpot最初是由一家“LongviewTechnologies”的小公司设计的,1997年此公司被Sun收购,JDK1.3时,,HotSpotVM成为Java默认的虚拟机。HotSpotVM的特性正如他的名字一样,它使用了热点代码探测技术:1.通过计数器找到最具有编译价值的代码,触发即使编译和桟上替换(OSR)2.通过编译器和解释器协同工作,在最优化程序响应时间和最佳的性能汇总取得平衡JRockitBEA公司(现在已经被Oracle收购)开发,是当前世界上最快的Java虚拟机,专注于服务端应用,全部靠编译器执行(JRockit内部不包含解析器的实现)。J9IBM开发原名:IBMTechn0ologyforJavaVirtualMachineIT4j。是除HotSpot、JRockit之外目前最有影响的三大商用虚拟机之一。KVMkilobyte简单、轻量、高度可移植,在手机平台运行,运行速度慢。AzulVM/LiquidVM高性能的Java虚拟机,在HotSpot基础上改进,专用的虚拟机。DalvikVM谷歌开发的,应用于Android系统,并且在Android2.2中提供了JITDalvikVM只能称作虚拟机,而不能称作“Java虚拟机”,因为他没有遵循Java虚拟机规范,因此不能直接执行Java的class文件采用了寄存器指令集架构而不是栈结构,执行dex(dalvikExecutable)文件。MicrosoftJVMMicrosoft为了在IE3浏览器中支持JavaApplets,开发了MicrosoftJVM,它只能运行在windows下面。1997年,Sun以商标侵权,不正当竞争罪名指控微软成功后,微软在WindowsXPSP3中去掉了MicrosoftJVM虚拟机,现在Windows上安装的JDK中都是HotSpotVM。TaobaoVM基于openJDK开发自己的定制版本AlibabJDK,简称AJDK。是整个阿里Java体系的基石。基于OpenJDK、HotSpotVM深度定制并且开源的高性能服务器版JVM。4、Java虚拟机的概念4.1虚拟机的分类虚拟机(VirtualMachinel),就是一台虚拟的计算机。它是一款软件,用来执行一系列虚拟计算机指令。大体上,虚拟机可以分为系统虚拟机和程序虚拟机。系统虚拟机:大名鼎鼎的VisualBox、VMware就属于系统虚拟机,它们完全是对真实计算机的仿真,提供了一个可运行完整操作系统的软件平台。程序虚拟机:典型代表就是Java虚拟机(JVM),它专门为执行单个计算机程序而设计,在Java虚拟机中执行的指令我们称作Java字节码指令。4.2Java虚拟机Java虚拟机是一台执行Java字节码的程序虚拟计算机,他拥有独立的运行机制,虽然他叫Java虚拟机,但其运行的字节码指令未必全是有Java语言编译而成,JVM是一个跨语言的平台。JVM平台的各种语言可以共享Java虚拟机带来的跨平台性、优秀的垃圾回收,以及可靠的即时编译器。Java技术的核心就是Java虚拟机(JVM),因为所有的Java程序最终运行在JVM内部。JVM的作用:JVM就是二进制字节码的运行环境,负责装载字节码到其内部,解释/编译为对应平台上的机器指令。JVM的特点:一次编译,到处运行;自动内存管理;自动垃圾回收功能......4.3JVM的整体结构上面介绍到JVM是一种用于专门执行单个计算机程序而设计的程序虚拟机,他主要包括类加载子系统、运行时数据区(内存结构)和执行引擎(包括垃圾回收器)三部分组成,关于JVM的组成如下图所示:图4.3JVM整体结构示意简图各部分功能简介1)类加载子系统负责从文件系统或者网络中加载Class信息,加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中可能还会存放运行时常量池信息,包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)。2)Java堆在虚拟机启动的时候建立,它是Java程序最主要的内存工作区域。几乎所有的Java对象实例都存放在Java堆中。堆空间是所有线程共享的,这是一块与Java应用密切相关的内存空间。3)Java的NIO库允许Java程序使用直接内存。直接内存是在Java堆外的、直接向系统申请的内存空间。通常访问直接内存的速度会优于Java堆。因此出于性能的考虑,读写频繁的场合可能会考虑使用直接内存。由于直接内存在Java堆外,因此它的大小不会直接受限于Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。4)垃圾回收系统是Java虚拟机的重要组成部分,垃圾回收器可以对方法区、Java堆和直接内存进行回收。其中,Java堆是垃圾收集器的工作重要场所。和C/C++不同,Java中所有的对象空间释放都是隐式的,也就是说,Java中没有类似free()或者delete()这样的函数释放指定的内存区域。对于不再使用的垃圾对象,垃圾回收系统会在后台默默工作,默默查找、标识并释放垃圾对象,完成包括Java堆、方法区和直接内存中的全自动化管理。5)每一个Java虚拟机线程都有一个私有的Java栈,一个线程的Java栈在线程创建的时候被创建,Java栈中保存着帧信息、局部变量、方法参数,同时和Java方法的调用、返回密切相关。6)本地方法栈和Java栈非常类似,最大的不同在于Java栈用于Java方法的调用,而本地方法栈则用于Native方法的调用,作为对Java虚拟机的重要扩展,Java虚拟机允许Java直接调用本地方法(通常使用C编写)7)**PC(ProgramCounterRegister)**也是每一个线程私有的空间,Java虚拟机会为每一个Java线程创建PC。在任意时刻,一个Java线程总是在执行一个方法,这个正在被执行的方法称为当前方法。如果当前方法不是本地方法,PC寄存器就会指向当前正在被执行的指令。如果当前方法是本地方法,那么PC寄存器的值就是undefined8)执行引擎是Java虚拟机的最核心组件之一,它负责执行虚拟机的字节码,现代虚拟机为了提高执行效率,会使用即时编译技术(JIT)将Java方法编译成机器码后再执行。4.4JVM的指令集架构模型Java编译器输入的指令流基本上是一种基于桟的指令集架构,另外一种指令集架构则是基于寄存器的指令集架构。具体来说,这两种指令集架构之间的区别如下:基于桟的指令集架构的特点设计和实现更简单,适用于资源受限的系统避开了寄存器分配难题,使用零地址指令方式分配;指令流中的指令大部分是零地址指令,其执行过程依赖于操作桟,指令集更小,编译器更容易实现不要需要依赖具体的硬件,可移植性好。基于寄存器的指令集架构放入特点典型的应用是x86的二进制指令集,比如传统的PC以及Android的Davlik虚拟机指令集完全依赖硬件,可移植性差性能更优秀,指令执行效率高花费更少指令去完成一项操作在大部分情况下,基于寄存器的架构的指令往往都以一地址指令、二地址指令和三地址指令为主。4.5JVM的生命周期4.5.1JVM的启动Java虚拟机的启动是通过引导类加载器(bootstarpclassloader)创建一个初始类来完成的,这个类是由虚拟机的具体实现指定的。4.5.2JVM的执行一个运行中的JVM有着一个清晰的任务:执行Java程序程序开始执行JVM才启动,程序执行结束JVM就停止执行一个所谓的Java程序的时候,真正在执行的其实是一个Java虚拟机进程。4.5.3JVM的退出以下几种情况JVM会退出:程序正常执行结束程序在执行过程中遇到了异常或错误而异常终止由于操作系统出现错误而导致Java虚拟机进程异常退出某个线程调用Runtime类或者System类的exit()方法,或调用了Runtime类的halt()方法,并且Java安全管理器也允许这次exit或halt操作除此之外,JNI(JavaNativeInterface)规范描述了用JNI来加载或卸载JVM时,JVM退出的情况

    LoveIT 2020-02-10
    JVM
    JVM