0%

内存溢出

java.lang.OutOfMemoryError,是指程序在申请内存时,没有足够的内存空间供其使用,出现OutOfMemoryError。

产生该错误的原因主要包括:

  • JVM内存过小。
  • 程序不严密,产生了过多的垃圾。

解决方法:

  1. 增加JVM的内存大小
    对于tomcat容器,找到tomcat在电脑中的安装目录,进入这个目录,然后进入bin目录中,在window环境下找到bin目录中的catalina.bat,在linux环境下找到catalina.sh。
    编辑catalina.bat文件,找到JAVA_OPTS(具体来说是 set “JAVA_OPTS=%JAVA_OPTS% %LOGGING_MANAGER%”)这个选项的位置,这个参数是Java启动的时候,需要的启动参数。
    也可以在操作系统的环境变量中对JAVA_OPTS进行设置,因为tomcat在启动的时候,也会读取操作系统中的环境变量的值,进行加载。
    如果是修改了操作系统的环境变量,需要重启机器,再重启tomcat,如果修改的是tomcat配置文件,需要将配置文件保存,然后重启tomcat,设置就能生效了。
  2. 优化程序,释放垃圾
    主要思路就是避免程序体现上出现的情况。避免死循环,防止一次载入太多的数据,提高程序健壮型及时释放。因此,从根本上解决Java内存溢出的唯一方法就是修改程序,及时地释放没用的对象,释放内存空间。

内存泄漏

Memory Leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

在Java中,内存泄漏就是存在一些被分配的对象,这些对象有下面两个特点。

  1. 首先,这些对象是可达的,即在有向图中,存在通路可以与其相连;
  2. 其次,这些对象是无用的,即程序以后不会再使用这些对象。

排查方法:

jstat命令格式为:

jstat [ option vmid [interval[s|ms] [count]] ]

使用命令如下:

jstat -gcutil 20954 1000

意思是每1000毫秒查询一次,一直查。gcutil的意思是已使用空间站总空间的百分比。

知道Eden Survivor Old区大小,以及GC时间

jmap:

jmap命令格式:

jmap [ option ] vmid

使用命令如下:

jmap -histo:live 20954

jvm

内存结构

ea2f6e47123182c285fbba70ff1d6080.png

方法区和堆是所有线程共享的内存区域;而java栈、本地方法栈和程序员计数器是运行是线程私有的内存区域。

  • Java堆(Heap),是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
  • 方法区(Method Area),方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 程序计数器(Program Counter Register),程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。
  • JVM栈(JVM Stacks),与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 本地方法栈(Native Method Stacks),本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

通常我们定义一个基本数据类型的变量,一个对象的引用,还有就是函数调用的现场保存都使用JVM中的栈空间;而通过new关键字和构造器创建的对象则放在堆空间,堆是垃圾收集器管理的主要区域,由于现在的垃圾收集器都采用分代收集算法,所以堆空间还可以细分为新生代和老生代,再具体一点可以分为Eden、Survivor(又可分为From Survivor和To Survivor)、Tenured;方法区和堆都是各个线程共享的内存区域,用于存储已经被JVM加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据;程序中的字面量(literal)如直接书写的100、”hello”和常量都是放在常量池中,常量池是方法区的一部分,。栈空间操作起来最快但是栈很小,通常大量的对象都是放在堆空间,栈和堆的大小都可以通过JVM的启动参数来进行调整,栈空间用光了会引发StackOverflowError,而堆和常量池空间不足则会引发OutOfMemoryError。

类加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。

类加载器

39f288db5465391b1f8a4d472b0aedd1.png

  • 启动类加载器:Bootstrap ClassLoader,负责加载存放在JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库
  • 扩展类加载器:Extension ClassLoader,该加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载DK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器。
  • 应用程序类加载器:Application ClassLoader,该类加载器由sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器

类加载原理

JVM中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java中的类加载器是一个重要的Java运行时系统组件,它负责在运行时查找和装入类文件中的类。

由于Java的跨平台性,经过编译的Java源程序并不是一个可执行程序,而是一个或多个类文件。当Java程序需要使用某个类时,JVM会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class文件中的数据读入到内存中,通常是创建一个字节数组读入.class文件,然后产生与所加载类对应的Class对象。加载完成后,Class对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后JVM对类进行初始化,包括:

  1. 如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;
  2. 如果类中存在初始化语句,就依次执行这些初始化语句。

类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader的子类)。

从Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM更好的保证了Java平台的安全性,在该机制中,JVM自带的Bootstrap是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM不会向Java程序提供对Bootstrap的引用。下面是关于几个类加载器的说明:

  • Bootstrap:一般用本地代码实现,负责加载JVM基础核心类库(rt.jar);
  • Extension:从java.ext.dirs系统属性所指定的目录中加载类库,它的父加载器是Bootstrap;
  • System:又叫应用类加载器,其父类是Extension。它是应用最广泛的类加载器。它从环境变量classpath或者系统属性java.class.path所指定的目录中记载类,是用户自定义加载器的默认父加载器。

类生命周期

fd7020a8b641573f4f7b5e51dc2c4cc4.png

  • 加载,查找并加载类的二进制数据,在Java堆中也创建一个java.lang.Class类的对象
  • 连接,连接又包含三块内容:验证、准备、初始化。
    1. 验证,文件格式、元数据、字节码、符号引用验证;
    2. 准备,为类的静态变量分配内存,并将其初始化为默认值;
    3. 解析,把类中的符号引用转换为直接引用
  • 初始化,为类的静态变量赋予正确的初始值
  • 使用,new出对象程序中使用
  • 卸载,执行垃圾回收

对象

创建过程

  1. JVM遇到一条新建对象的指令时首先去检查这个指令的参数是否能在常量池中定义到一个类的符号引用。然后加载这个类
  2. 为对象分配内存。一种办法“指针碰撞”、一种办法“空闲列表”,最终常用的办法“本地线程缓冲分配(TLAB)”
  3. 将除对象头外的对象内存空间初始化为0
  4. 对对象头进行必要设置

对象组成

由三个部分组成,对象头、实例数据、对齐填充。

对象头由两部分组成

  1. 存储对象自身的运行时数据:哈希码、GC分代年龄、锁标识状态、线程持有的锁、偏向线程ID(一般占32/64 bit)。
  2. 指针类型,指向对象的类元数据类型(即对象代表哪个类)。如果是数组对象,则对象头中还有一部分用来记录数组长度。

实例数据用来存储对象真正的有效信息(包括父类继承下来的和自己定义的)

对齐填充:JVM要求对象起始地址必须是8字节的整数倍(8字节对齐)

垃圾回收

判断对象可以被回收

判断对象是否存活一般有两种方式:

  • 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
  • 可达性分析(Reachability Analysis):从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,不可达对象。

引用分类

  • 强引用:GC时不会被回收
  • 软引用:描述有用但不是必须的对象,在发生内存溢出异常之前被回收
  • 弱引用:描述有用但不是必须的对象,在下一次GC时被回收
  • 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用PhantomReference实现虚引用,虚引用用来在GC时返回一个通知。

回收算法

GC最基础的算法有三种: 标记 -清除算法、复制算法、标记-压缩算法,我们常用的垃圾回收器一般都采用分代收集算法。

  • 标记 -清除算法,“标记-清除”(Mark-Sweep)算法,如它的名字一样,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
  • 复制算法,“复制”(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 标记-压缩算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存
  • 分代收集算法,“分代收集”(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。

分代

新生代(Young)

新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70% ~ 95% 的空间,回收效率很高。

HotSpot将新生代划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1。划分的目的是因为HotSpot采用复制算法来回收新生代,设置这个比例是为了充分利用内存空间,减少浪费。新生成的对象在Eden区分配(大对象除外,大对象直接进入老年代),当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC。

GC开始时,对象只会存在于Eden区和From Survivor区,To Survivor区是空的(作为保留区域)。GC进行时,Eden区中所有存活的对象都会被复制到To Survivor区,而在From Survivor区中,仍存活的对象会根据它们的年龄值决定去向,年龄值达到年龄阀值(默认为15,新生代中的对象每熬过一轮垃圾回收,年龄值就加1,GC分代年龄存储在对象的header中)的对象会被移到老年代中,没有达到阀值的对象会被复制到To Survivor区。接着清空Eden区和From Survivor区,新生代中存活的对象都在To Survivor区。接着, From Survivor区和To Survivor区会交换它们的角色,也就是新的To Survivor区就是上次GC清空的From Survivor区,新的From Survivor区就是上次GC的To Survivor区,总之,不管怎样都会保证To Survivor区在一轮GC后是空的。GC时当To Survivor区没有足够的空间存放上一次新生代收集下来的存活对象时,需要依赖老年代进行分配担保,将这些对象存放在老年代中。

老年代(Old)

在新生代中经历了多次(具体看虚拟机配置的阀值)GC后仍然存活下来的对象会进入老年代中。老年代中的对象生命周期较长,存活率比较高,在老年代中进行GC的频率相对而言较低,而且回收的速度也比较慢。

永久代(Permanent)

永久代存储类信息、常量、静态变量、即时编译器编译后的代码等数据,对这一区域而言,Java虚拟机规范指出可以不进行垃圾收集,一般而言不会进行垃圾回收。

垃圾回收不会发生在永久代,如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果你仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免Full GC是非常重要的原因。请参考下Java8:从永久代到元数据区 (注:Java8中已经移除了永久代,新加了一个叫做元数据区的native内存区)

垃圾回收器

串行垃圾回收器

GC线程只有一个,它会暂停所有工作线程,一个一个内存区域来收集,不适合服务器环境。通过JVM命令-XX:+UseSerialGC可以使用串行垃圾回收器。串行回收器也有两种:1.Serial:只对新生代使用;2.Serial Old:只对老年代使用,采用的算法不一样(一般作为CMS的替补)

并行垃圾回收器

GC使用多线程进行垃圾回收。通过JVM命令-XX:+UseParallGC可以使用并行垃圾回收器。并行回收器有三种:1.ParNew,作用于新生代; 2.Parallel Scavenge 作用于新生代,但以吞吐量为主;3.Parallel Old,作用于老年代,也已吞吐量为主,配合2使用。

并发标记扫描垃圾回收器(CMS)

多线程,标记清理(Full GC的时候用)通过JVM命令 -XX:+UseConcMarkSweepGC使用, 主要用于老生代,策略为:

年老代只有两次短暂停,其他时间应用程序与收集线程并发的清除。采用两次短暂停来替代标记整理算法的长暂停,它的收集周期:  

初始标记(CMS-initial-mark) -> 并发标记(CMS-concurrent-mark) -> 重新标记(CMS-remark)-> 并发清除(CMS-concurrent-sweep) ->并发重设状态等待下次CMS的触发(CMS-concurrent-reset)。

它的主要适合场景是对响应时间的重要性需求大于对吞吐量的要求,能够承受垃圾回收线程和应用线程共享处理器资源,并且应用中存在比较多的长生命周期的对象的应用。但CMS收集算法在最为耗时的内存区域遍历时采用多线程并发操作,对于服务器CPU资源不够的情况下,其实对性能是没有提升的,反而会导致系统吞吐量的下降;

G1垃圾回收器

适用于堆内存很大的情况,它将对内存分割成不同的区域,并且并发的对其进行回收,回收后对剩余内存压缩,标记整理,服务器端适用。

缺点:

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint) 还是程序运行时的额外执行负载(overload) 都要比CMS要高。
    从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用,上则发挥其优势。平衡点在6一8GB之间。

对象分配规则

  • 对象优先分配在Eden区,如果Eden区没有足够的空间时,虚拟机执行一次Minor GC。
  • 大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。这样做的目的是避免在Eden区和两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
  • 长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC那么对象会进入Survivor区,之后每经过一次Minor GC那么对象的年龄加1,知道达到阀值对象进入老年区。
  • 动态判断对象的年龄。如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
  • 空间分配担保。每次进行Minor GC时,JVM会计算Survivor区移至老年区的对象的平均大小,如果这个值大于老年区的剩余值大小则进行一次Full GC,如果小于检查HandlePromotionFailure设置,如果true则只进行Monitor GC,如果false则进行Full GC。

Minor GC与Full GC

新生代GC(Minor GC):Minor GC指发生在新生代的GC,因为新生代的Java对象大多都是朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快。当Eden空间不足以为对象分配内存时,会触发Minor GC。

老年代GC(Full GC/Major GC):Full GC指发生在老年代的GC,出现了Full GC一般会伴随着至少一次的Minor GC(老年代的对象大部分是Minor GC过程中从新生代进入老年代),比如:分配担保失败。Full GC的速度一般会比Minor GC慢10倍以上。当老年代内存不足或者显式调用System.gc()方法时,会触发Full GC。

synchronized

作用

  1. 修饰普通方法
  2. 修饰静态方法
  3. 修饰代码块

原理

加锁:monitorenter

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

释放锁:monitorexit

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

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

详见《锁》

Synchronized & ReentrantLock

相同点:

  1. 协调多线程对共享对象、变量的访问
  2. 可重入,同一线程可以多次获得同一个锁
  3. 都保证了可见性和互斥性

不同点:

  1. ReentrantLock显示获得、释放锁,synchronized隐式获得释放锁。synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
  2. ReentrantLock可响应中断、可轮回,为处理锁的不可用性提供了更高的灵活性。使用synchronized时,等待的线程会一直等待下去,不能够响应中断
  3. ReentrantLock是API级别的,synchronized是JVM级别的
  4. ReentrantLock可以实现公平锁,synchronized是非公平的
  5. ReentrantLock通过Condition可以绑定多个条件。一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多余一个条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这么做,只需要多次调用new Condition()方法即可。
  6. 底层实现不一样, synchronized是同步阻塞,使用的是悲观并发策略,lock是同步非阻塞,采用的是乐观并发策略

happens-before

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  5. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
  6. join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-7. before于线程A从ThreadB.join()操作成功返回。
  7. 程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
    对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。

wait/notify/sleep/yield/join

wait

wait的三种方法:

  • wait()方法的作用是将当前运行的线程挂起(即让其进入阻塞状态),直到notify或notifyAll方法来唤醒线程.
  • wait(long timeout),该方法与wait()方法类似,唯一的区别就是在指定时间内,如果没有notify或notifAll方法的唤醒,也会自动唤醒
  • 至于wait(long timeout,long nanos),本意在于更精确的控制调度时间,不过从JDK1.8来看,该方法貌似没有完整的实现该功能

wait方法的使用必须在同步的范围内,否则就会抛出IllegalMonitorStateException异常,wait方法的作用就是阻塞当前线程等待notify/notifyAll方法的唤醒,或等待超时后自动唤醒。

notify/notifyAll

如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争

优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。

sleep/yield/join

sleep:sleep方法的作用是让当前线程暂停指定的时间(毫秒),sleep方法是最简单的方法,在上述的例子中也用到过,比较容易理解。唯一需要注意的是其与wait方法的区别。最简单的区别是,wait方法依赖于同步,而sleep方法可以直接调用。而更深层次的区别在于sleep方法只是暂时让出CPU的执行权,并不释放锁。而wait方法则需要释放锁。

yield:yield方法的作用是暂停当前线程,以便其他线程有机会执行,不过不能指定暂停的时间,并且也不能保证当前线程马上停止。yield方法只是将Running状态转变为Runnable状态。

  1. 调度器可能会忽略该方法。
  2. 使用的时候要仔细分析和测试,确保能达到预期的效果。
  3. 很少有场景要用到该方法,主要使用的地方是调试和测试。

join:join方法的作用是父线程等待子线程执行完成后再执行,换句话说就是将异步执行的线程合并为同步的线程。JDK中提供三个版本的join方法,其实现与wait方法类似,join()方法实际上执行的join(0),而join(long millis, int nanos)也与wait(long millis, int nanos)的实现方式一致,暂时对纳秒的支持也是不完整的。join方法就是通过wait方法来将线程的阻塞,如果join的线程还在执行,则将当前线程阻塞起来,直到join的线程执行完成,当前线程才能执行。不过有一点需要注意,这里的join只调用了wait方法,却没有对应的notify方法,原因是Thread的start方法中做了相应的处理,所以当join的线程执行完成以后,会自动唤醒主线程继续往下执行。

闭锁、信号量、栅栏

Semaphore信号量

跟锁机制存在一定的相似性,semaphore也是一种锁机制,所不同的是,reentrantLock是只允许一个线程获得锁,而信号量持有多个许可(permits),允许多个线程获得许可并执行。可以用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。

CountDownLatch闭锁

允许一个或多个线程一直等待,直到其他线程的操作执行完后再执行。CountDownLatch是通过一个计数器来实现的,计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就会减1。当计数器值到达0时,它表示所有的线程已经完成了任务,然后在闭锁上等待的线程就可以恢复执行任务。

主要方法:

  1. CountDownLatch.await():将某个线程阻塞住,直到计数器count=0才恢复执行。
  2. CountDownLatch.countDown():将计数器count减1。

使用场景:

  1. 实现最大的并行性:有时我们想同时启动多个线程,实现最大程度的并行性。例如,我们想测试一个单例类。如果我们创建一个初始计数为1的CountDownLatch,并让所有线程都在这个锁上等待,那么我们可以很轻松地完成测试。我们只需调用 一次countDown()方法就可以让所有的等待线程同时恢复执行。
  2. 开始执行前等待n个线程完成各自任务:例如应用程序启动类要确保在处理用户请求前,所有N个外部系统已经启动和运行了。
  3. 死锁检测:一个非常方便的使用场景是,你可以使用n个线程访问共享资源,在每次测试阶段的线程数目是不同的,并尝试产生死锁。
  4. 计算并发执行某个任务的耗时。

CyclicBarrier栅栏

用于阻塞一组线程直到某个事件发生。所有线程必须同时到达栅栏位置才能继续执行下一步操作,且能够被重置以达到重复利用。而闭锁是一次性对象,一旦进入终止状态,就不能被重置。

区别

  1. 闭锁用来等待事件,就是说闭锁用来等待的事件就是countDown事件,只有该countDown事件执行后所有之前在等待的线程才有可能继续执行;而栅栏没有类似countDown事件控制线程的执行,只有线程的await方法能控制等待的线程执行。
  2. 栅栏用来等待线程,CyclicBarrier强调的是n个线程,大家相互等待,只要有一个没完成,所有线程都得等着。

闭锁是一次性对象,一旦进入终止状态,就不能重置。而栅栏可以使一定数量的参入方反复的在栅栏位置汇集。

并发编程实战笔记

  1. synchronized内置锁可重入:即线程获取锁后在获取已获锁会成功。_操作粒度为线程而非调用,即线程可重复获取已持有的锁而非单次调用_。获取一次计数值+1,退出一次代码块计数值-1.
  2. 当获取与对象关联的锁时,并不能阻止其他线程获操作对象访问该对象。某个线程在获得对象的锁之后,只能阻止其他线程获得同一个锁。(OneClass.class和OneClass.oneAttribute是两个不同的锁,前者并不包括后者。对于一个静态属性SC.SA,SC.SA锁与其实例SC.SA锁不是同一个锁,既是他们代表的是同一个东西。同样,SC.class锁与SC锁也不能保证SA线程安全)
  3. 多线程中使用共享且可变的long/double类型变量是不安全的,因为JVM读取64位数据时会折分为2个32位(除非用volatile关键字或者加锁)。因此long,double无最低安全性(也就是volatile long/double 读写是原子的)
  4. volatile变量不会被重排序,不会被缓存在寄存器或者其他处理器不可见的位置。因此读取volatile变量总会返回最新的写入值。volatile开销很低,只比无volatile高一点点。
  5. 当某个对象封闭在一个线程中时,这种用法将自动实现线程安全性,既是被封闭的对象本身不是线程安全的。(线程封闭:不与其他线程共享数据)
  6. 任何线程都可以在不需要额外同步的情况下安全地访问不可变对象,即使在发布这些对象的时候没有使用同步。
  7. 对于集合,在使用迭代器对其遍历过程中,增加或者删除而不是通过Iterator.remove删除会引发ConcurrentModificationException(快速失败),快速失败是善意的,虽然报错但语句会执行成功。HashMap和Vector快速失败机制则不同。synchronized不会阻止快速失败。(隐式迭代:Collection.toString()。当容器作为另一个容器的元素或者键值时,hashCode()和equals()方法也有迭代)。安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。
  8. ConcurrentHashMap:分段锁,JDK1.8中采用HashEntry<K,V>保存数据,对数组每一行加锁。结构:数组+单向链表+红黑树
  9. CopyonWrite…:在迭代时共享资源,在修改是先创建一个副本,更新后代替原有的使用。
  10. 工作密取:每个消费者有自己的双端队列,完成了自己队列的全部工作时,可以从其他队列队尾秘密地获取工作。适用于执行某个工作可能导致产生更多工作的情况(例:网页爬虫)
  11. BlockingQueue:put(),take()在队空或者队满时会进入阻塞,当被interrupt()则会抛出InterruptedException。而offer()和poll()不会。
    处理InterruptedException:
    • 传递,即直接抛出或简单处理后抛出
    • 不能传递时调用interrupt(),产生一个中断
      中断是协助性质的,它不会强制终止或阻塞线程,二是由被interrupt的线程决定作出什么响应。
      interrupt()会使被Object.wait(),Thread.join(),Thread.sleep()阻塞的线程退出阻塞状态。
  12. 闭锁:延迟线程的进度直到其到达终止状态,之后会永远保持开启状态。
    CountDownLatch:计数器到0时开放闭锁。方法:await()加入闭锁,countDown()减一,计数器初始值在构造函数中。
    FutureTask:先run()后get(),get()会阻塞知道结果产生,相当于闭锁。可以通过Thread t=new Thread(FutureTask f);t.start();提前开始任务,f.get()可减少等待时间,因为FutureTask implements Future,Runable.不能使用f.run(),这个方法也会阻塞到运行结束,和get()一样
  13. 信号量:Semaphore:用于控制访问或则操作的数量。acquire()会阻塞直到有许可(或被中断或超时)。relase()会释放一个信号量(有可能使可用信号量超过初始信号量数)
  14. 栅栏cyclicBarrier:
    1. 多个线程在栅栏中等待时,其中一个线程调用interrupt()方法,此线程会突破栅栏并报出InterruptException,其他线程也会突破栅栏并报BrockingBarrierException异常。
    2. 构造方法CyclicBarrier(int parices,Runable barrierAction)中,后一个是一个线程,当栅栏中等待的线程到达阈值时会先执行barrierAction,可用于执行合并处理
  15. ConcurrentHashMap.putIfAbsent(K,V)如果没有则放入
  16. FutureTask cancel(boolean mayInterruptIfRunning)用于取消任务,参数表示是否向正在工作的任务发送中断 cancel()后调用get()会报错CancellationException,是默认cache的错误不包括这个错误,程序会终止运行。isCancelled()可判断是否取消
  17. ExecutorService.invokeAll(collection,time,timeUnie)将callable集合,最长等待时间,时间单位传入,返回一个Future的集合(与callable集合相同),超时未运行结束的任务会被cancel()
  18. ComplesionService内部维护一个BlockingQueue来保存状态为”结束“的Future,submit(callable)提交任务,take()获取Future,解决了几个任务会等待一个Future.get()阻塞至产生结果的问题
  19. ExecutorService内部维护的线程任务在程序结束后不会自动销毁,需要调用shutdown()(Runable和Callable都不会,submit(Runable)会产生一个Future(Runable,null)),而单纯的FutureTask不需要(单纯的FutureTask需要点调用run()之后get()才会有结果)
  20. sleep()与wait()区别:sleep()让线程暂停工作一段时间,但不是放对象锁。wait()释放对象锁,并使本线程进入等待状态,等待后续再次获取对象锁。

ReentrantLock

ReentrantLock意思为可重入锁,指的是一个线程能够对一个临界资源重复加锁。

与synchronized区别:

2c86ebbb6ef7c91ff684b1a20a0df9fe.png

AQS

AQS

Java中的大部分同步类(Lock、Semaphore、ReentrantLock等)都是基于AbstractQueuedSynchronizer(简称为AQS)实现的。AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架。

69f6def2ef2d7f369d1c0a8a2babd99c.png

aba28034497db0e471cd063e5a36d8bd.png

  • 上图中有颜色的为Method,无颜色的为Attribution。
  • 总的来说,AQS框架共分为五层,自上而下由浅入深,从AQS对外暴露的API到底层基础数据。
  • 当有自定义同步器接入时,只需重写第一层所需要的部分方法即可,不需要关注底层具体的实现流程。当自定义同步器进行加锁或者解锁操作时,先经过第一层的API进入AQS内部方法,然后经过第二层进行锁的获取,接着对于获取锁失败的流程,进入第三层和第四层的等待队列处理,而这些处理方式均依赖于第五层的基础数据提供层。

原理概述

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

主要原理图如下:

5dae8bc020671d610326b73185a6f8be.png

AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

Elasticsearch

基本概念

You know, for search (and analysis)

基于Lucene的分布式全文搜索引擎

  • 分布式文件存储
  • 分布式(准)实时搜索引擎,每个字段都被索引并可被搜索
  • 分布式(准)实时分析工具,聚合功能可以构建复杂数据摘要
  • 开箱即用。安装好ElasticSearch后,所有参数的默认值都自动进行了比较合理的设置,基本不需要额外的调整。包括内置的发现机制(比如Field类型的自动匹配)和自动化参数配置。
  • 天生集群。ElasticSearch默认工作在集群模式下。节点都将视为集群的一部分,而且在启动的过程中自动连接到集群中。
  • 自动容错。ElasticSearch通过P2P网络进行通信,这种工作方式消除了单点故障。节点自动连接到集群中的其它机器,自动进行数据交换及以节点之间相互监控。索引分片
  • 扩展性强。无论是处理能力和数据容量上都可以通过一种简单的方式实现扩展,即增添新的节点。
  • 近实时搜索和版本控制。由于ElasticSearch天生支持分布式,所以延迟和不同节点上数据的短暂性不一致无可避免。ElasticSearch通过版本控制(versioning)的机制尽量减少问题的出现。

分布式

  • 节点(Node):一个运行的Elasticearch实例。分为主节点、数据节点、协调节点、部落节点
  • 索引(Index),逻辑概念,包括配置信息mapping和倒排正排数据文件
  • 分片(Shard):分为主分片和副本分片。索引按某个维度分成多个部分。一个节点(Node)一般会管理来自多个索引的多个分片,同一个索引的分片尽量会分布在不同节点(Node)上。
  • 副本(Replica):同一个分片(Shard)的备份数据,分片有>=0个副本。

文件存储

  • Index/索引:Index是一类文档的集合,是具有相同业务特征的数据文档集合(不是相同数据结构),相当于传统数据库中的数据库。ES数据的索引、搜索和分析都是基于索引完成的。Cluster中可以创建任意个Index。每个Index(对应Database)包含多个Shard,默认是5个,分散在不同的Node上,但不会存在两个相同的Shard(相同的主和复制)存在一个Node上。当索引创建完成的时候,主分片的数量就固定了,但是复制分片的数量可以随时调整。
  • Type/类型:Type是Index中数据的 ,用于标识不同的文档字段信息的集合,相当于传统数据库的表。在0之后的版本直接做了插入检查,禁止一个索引下不同Type的字段类型冲突。举例来说,在一个博客系统中,你可以定义一个 user type,可以定义一个 blog type,还可以定义一个 comment type。
  • Document/文档:Document是ES数据可被索引化的基本的存储单元,需要存储在Type中,相当于传统数据库的行记录,使用json来表示。

c021d8ded5c9cf4227a5c699e9700f49.png

搜索

  • 结构化查询
  • 全文搜索
  • 嵌套搜索
  • 模糊匹配
  • 相关度查询

分析

  • 时序分析
  • 聚合日志
  • 数据的可视化
  • 数据图谱分析
  • 地理位置分析

原理

分布式

主节点作用:

  • 主节点上有个单独的进程处理 ClusterState(保存集群的状态,索引/分片的路由表,节点列表,元数据等信息的数据结构)的变更操作,每次变更会更新版本号。变更后会通过PRC接口同步到其他节点。主节知道其他节点的ClusterState 的当前版本,发送变更的时候会做diff,实现增量更新。
  • 创建或删除索引
  • 跟踪哪些节点是群集的一部分,并决定哪些分片分配给相关的节点

容灾实现:

集群状态:

  • 绿色——最健康的状态,代表所有的主分片和副本分片都可用;
  • 黄色——所有的主分片可用,但是部分副本分片不可用;
  • 红色——部分主分片不可用。

Elasticsearch的恢复流程大致如下:

  1. 集群中的某个节点丢失网络连接
  2. master提升该节点上的所有主分片的在其他节点上的副本为主分片cluster
  3. 集群状态变为 yellow ,因为副本数不够
  4. 等待一个超时设置的时间,如果丢失节点回来就可以立即恢复(默认为1分钟,通过 index.unassigned.node_left.delayed_timeout 设置)。如果该分片已经有写入,则通过translog进行增量同步数据。
  5. 否则将副本分配给其他节点,开始同步数据。

选主:

什么时候选主:

  1. 集群启动
  2. Master 失效

为什么不用zk/etcd:

es 集群相对较小,而且拓扑结构相对不容易变动。简单场景简单做。

选举原理:

Bully是Leader选举的基本算法之一。 它假定所有节点都有一个惟一的ID,该ID对节点进行排序。 任何时候的当前Leader都是参与集群的最高id节点。 该算法的优点是易于实现,但是,当拥有最大 id 的节点处于不稳定状态的场景下会有问题,例如 Master 负载过重而假死,集群拥有第二大id 的节点被选为 新主,这时原来的 Master 恢复,再次被选为新主,然后又假死

过程:

  1. 各个节点广播消息,获取集群信息,找出候选节点
  2. 找到id最小的候选节点,推举为临时master节点
  3. 选举
    1. 自认为是临时master的节点开始收集投票
    2. 自认为不是临时master的节点投票给它认为是临时master的节点临时
  4. master收集到一定票数时(为防止脑裂,票数阈值一般是 候选节点总数/2+1)变成主节点,开始构建集群

节点间transport层结构:

每个node间维护13个连接,分别负责恢复数据、传输批量请求、传输正常请求、传输状态、ping

290ed47b83070ef1173fb48b0fa59dd7.png

分片:

Elasticsearch 的分片默认是基于id 哈希的。

总分片数是不能变动的。副本分片数可以变化。分片切分成本和重新索引的成本差不多,所以建议干脆通过接口重新索引。

每一个分片都是一个单独的Lucene索引。

3c638e636665c46cbc4895e821308bc2.png

副本分片和主分片不会放在同一个节点内。

每个节点都有能力处理任意请求。每个节点都知道任意文档所在的节点,所以也可以将请求转发到需要的节点。举例:索引一个新文档

f5b349d660c60b7bdc9b8ccc6a3fee8b.png

Lucene索引

ea35056f56c0f16a442db0140d179da7.png

  1. 多线程并发调用 IndexWriter 的写接口,在 IndexWriter 内部具体请求会由 DocumentsWriter 来执行。DocumentsWriter 内部在处理请求之前,会先根据当前执行操作的 Thread 来分配 DocumentsWriterPerThread。(加锁)
  2. 每个线程在其独立的 DocumentsWriterPerThread 空间内部进行数据处理,包括分词、相关性计算、索引构建等。
  3. 数据处理完毕后,在 DocumentsWriter 层面执行一些后续动作,例如触发 FlushPolicy 的判定等。(加锁)

分段存储:

每个索引被分成了多个段(Segment,由域信息(Field information)、词信息(Term information)、以及其它信息(标准化因子、删除文档)组成),段具有一次写入,多次读取的特点。只要形成了,段就无法被修改。例如:被删除文档的信息被存储到一个单独的文件,但是其它的段文件并没有被修改。

索引过程:

[18,20][女,男]叫term

[1,3][2] 叫posting list

988c1333e452996556b759344e0ebb90.png

过程:

  1. Lucene用户指定好的analyzer解析用户添加的Document。当然Document中不同的Field可以指定不同的analyzer。
  2. term索引
    1. 字符关键词检索
      1. term index:树形结构,通过记录term dictionary的某个前缀的offset,然后从这个位置再往后顺序查找。
        cfec4292c2e5537eecc1b1847ab04018.png
        构建Term Dictionary
      2. FST不但能共享前缀还能共享后缀。不但能判断查找的key是否存在,还能给出响应的输入output。 它在时间复杂度和空间复杂度上都做了最大程度的优化,使得Lucene能够将Term Dictionary完全加载到内存,快速的定位Term找到响应的output(posting倒排列表)
        %!(EXTRA markdown.ResourceType=, string=, string=)
        利用FST(Finite State Transducer,一种类似tire树的有限状态机)压缩term dictionary到内存
    2. 所以为了支持高效的数值类或者多维度查询,lucene 引入类 BKDTree。BKDTree 是K-D树和 B+ Tree树的结合体,对数据进行按照维度划分建立一棵二叉树确保树两边节点数目平衡。在一维的场景下(一维(如整型字段)、二维(如地理坐标类型字段)),KDTree 就会退化成一个二叉搜索树,在二叉搜索树中如果我们想查找一个区间,logN 的复杂度就会访问到叶子结点得到对应的倒排链。如下图所示:
      %!(EXTRA markdown.ResourceType=, string=, string=)
      优点:可以局部更新
      数值关键词
  3. Elasticsearch要求posting list是有序的,有序的posting list可以大大提高搜索速度(特别是面对稀疏索引的场景的时候)。
    es利用Frame Of Reference/Roaring bitmaps 压缩posting list,使得其占用更小的空间,同时还可以满足使用使用跳表进行联合索引过滤
    posting list索引
    1. 使用(id/65535,id%65535)的格式存储数据
      ef21645db82253aa68c9901274cf8491.png
      roaring bitmaps
    2. 增量编码压缩,将大数变小数,按字节存储
      %!(EXTRA markdown.ResourceType=, string=, string=)
      Frame Of Reference

Lucene搜索

segment段内搜索

  1. 用户的输入查询语句将被选定的查询解析器(query parser)所解析,生成多个Query对象。当然用户也可以选择不解析查询语句,使查询语句保留原始的状态。在ElasticSearch中,有的Query对象会被解析(analyzed),有的不会,比如:前缀查询(prefix query)就不会被解析,精确匹配查询(match query)就会被解析。
  2. term搜索(关键词搜索)
  3. 为了能够快速查找 docid,lucene 采用了 SkipList 这一数据结构。SkipList 有以下几个特征:
    posting list搜索
    1. 元素排序的,对应到我们的倒排链,lucene 是按照 docid 进行排序,从小到大。
    2. 跳跃有一个固定的间隔,这个是需要建立 SkipList 的时候指定好,例如下图以间隔是 3
    3. 3eeb8ac70977606c166b3365ffbe3922.png
      SkipList 的层次,这个是指整个 SkipList 有几层

倒排合并

5a4bcbc4824a5df6d0e895b9fc6aa03f.png

在 lucene 中会采用下列顺序进行合并:

  1. 在 termA 开始遍历,得到第一个元素 docId=1
  2. Set currentDocId=1
  3. 在 termB 中 search(currentDocId) = 1 (返回大于等于 currentDocId 的一个 doc),
    1. 因为 currentDocId ==1,继续
    2. 如果 currentDocId 和返回的不相等,执行 2,然后继续

打分

默认是TD-IDF

ES支持重打分。不详细说了

准实时

translog

提供了实时的数据读取能力以及完备的数据持久化能力(在服务器异常挂掉的情况下依然不会丢数据)。Lucene 因为有 IndexWriter buffer, 如果进程异常挂掉,buffer中的数据是会丢失的。所以 Elasticsearch 通过translog来确保不丢数据。同时通过id直接读取文档的时候,Elasticsearch 会先尝试从translog中读取,之后才从索引中读取。也就是说,即便是buffer中的数据尚未刷新到索引,依然能提供实时的数据读取能力。Elasticsearch 的translog 默认是每次写请求完成后统一fsync一次,同时有个定时任务检测(默认5秒钟一次)。如果业务场景需要更大的写吞吐量,可以调整translog相关的配置进行优化。

%!(EXTRA markdown.ResourceType=, string=, string=)

refresh

  • 所有在内存缓冲区中的文档被写入到一个新的segment中,但是没有调用fsync,因此内存中的数据可能丢失
  • segment被打开使得里面的文档能够被搜索到
  • 清空内存缓冲区

%!(EXTRA markdown.ResourceType=, string=, string=)

flush

  • 把所有在内存缓冲区中的文档写入到一个新的segment中
  • 清空内存缓冲区
  • 往磁盘里写入commit point信息
  • 文件系统的page cache(segments) fsync到磁盘
  • %!(EXTRA markdown.ResourceType=, string=, string=)
    删除旧的translog文件,因此此时内存中的segments已经写入到磁盘中,就不需要translog来保障数据安全了

使用场景

常用场景

  • 记录和日志分析
  • 事件数据和指标
  • 数据可视化
  • 全文搜索

其他资料

mongodb vs es

solr vs es

基础

java hashmap

并发编程

volatile关键字

concurrenthashmap

框架

dubbo面试题

动态代理

gdk

利用拦截器(拦截器必须实现InvocationHanlder)加上反射机制生成一个实现代理接口的匿名类,

在调用具体方法前调用InvokeHandler来处理。

JDK动态代理主要涉及两个类:java.lang.reflect.Proxy 和 java.lang.reflect.InvocationHandler

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class Main {
public static void main(String[] args) {
InvocationHandler handler = new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println(method);
if (method.getName().equals("morning")) {
System.out.println("Good morning, " + args[0]);
}
return null;
}
};
Hello hello = (Hello) Proxy.newProxyInstance(
Hello.class.getClassLoader(), // 传入ClassLoader
new Class[] { Hello.class }, // 传入要实现的接口
handler); // 传入处理调用方法的InvocationHandler
hello.morning("Bob");
}
}

interface Hello {
void morning(String name);
}

输出:

public abstract void Hello.morning(java.lang.String)

Good morning, Bob

流程:

  1. 定义一个InvocationHandler实例,它负责实现接口的方法调用;
  2. 通过Proxy.newProxyInstance()创建interface实例,它需要3个参数:
    1. 使用的ClassLoader,通常就是接口类的ClassLoader;
    2. 需要实现的接口数组,至少需要传入一个接口进去;
    3. 用来处理接口方法调用的InvocationHandler实例。
  3. 将返回的Object强制转型为接口。

cglib

利用ASM开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来处理。

何时使用JDK还是CGLIB?

  1. 如果目标对象实现了接口,默认情况下会采用JDK的动态代理实现AOP。
  2. 如果目标对象实现了接口,可以强制使用CGLIB实现AOP。
  3. 如果目标对象没有实现了接口,必须采用CGLIB库,Spring会自动在JDK动态代理和CGLIB之间转换。

JDK动态代理和CGLIB字节码生成的区别?

  1. JDK动态代理只能对实现了接口的类生成代理,而不能针对类。
  2. CGLIB是针对类实现代理,主要是对指定的类生成一个子类,覆盖其中的方法,
    并覆盖其中方法实现增强,但是因为采用的是继承,所以该类或方法最好不要声明成final,
    对于final类或方法,是无法继承的。

Spring如何选择用JDK还是CGLIB?

  1. 当Bean实现接口时,Spring就会用JDK的动态代理。
  2. 当Bean没有实现接口时,Spring使用CGlib是实现。
  3. 可以强制使用CGlib(在spring配置中加入<aop:aspectj-autoproxy proxy-target-/>)。