JVM和并发编程总结

文章目录

1 JVM相关

1.1 JVM内存结构

JVM有五大内存区域:

① 程序计数器:线程私有,记录字节码指令地址,记录当前程序执行到字节码文件的第几行。

② Java栈(虚拟机栈):线程私有,生命周期与线程相同,虚拟机栈当中存储的是帧,帧与帧之间不一定是连续内存;

  • :每调用一个方法就创建一个帧,每一个帧都有自己的本地变量数组和操作数据栈,这个内存大小在编译时就确定了。在线程执行的任何一个时刻,都只有一个帧是激活状态,称为当前帧;
    • 本地变量数组:每一帧都包含一个变量数组,就是都熟知的本地变量存储的地方
    • 操作数栈:每个帧包含一个后进先出的栈,用于存储正在执行的jvm指令的操作数
    • 动态链接:将符号引用装换成具体方法的引用

③ 本地方法栈:线程私有,作用于虚拟机栈作用类似,只是本地方法栈是为native方法服务。

④ 堆:线程共享。是java虚拟机管理内存最大的一块内存区域,所有对象实例及数组都要在堆上分配内存。

  • 从内存分配的角度,Java堆中有多个线程私有的分配缓冲区,用于线程高速的分配内存空间,读取还是线程共享的。解决多个线程分配内存空间的同步效率问题
  • 从GC角度,Java堆可以分为老年代和新生代,新生代又分为Eden、S0、S1区域。

⑤ 方法区(1.8之前):线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量。1.8之后的方法区在元空间(本地内存),不受JVM控制,不进行GC,通过其他方式进行垃圾回收。

1.2 Java内存模型(JMM)

Java内存模型定义了线程与主内存之间的抽象关系,所有的共享变量都存储在主内存中,每个线程还有自己的本地内存,本地内存中存储的是主内存中共享变量的副本,线程对变量的所有操作都只能在本地内存中操作,不能直接读写主内存。

volatile的作用就是当线程修改被volatile修饰的变量时,要立即写入到主内存,当线程读取被volatile修饰的变量时,要立即到主内存中去读取,保证了可见性。JVM向CPU发送一个LOCK指令,表示该变量的缓存行要刷新到内存,而缓存行刷新到内存会导致其他处理器的该数据的缓存行失效,也就意味着其他线程会到内存中读取数据。

1.3 JVM垃圾回收算法

1.3.1 哪些内存需要垃圾回收

程序计数器、虚拟机栈、本地方法栈是随着线程自生自灭,而方法区和堆内存则需要垃圾回收。

1.3.2 判断对象存活算法

  • 引用计数法:给对象添加引用计数器,优点:效率高 缺点:难以解决循环引用问题(node1.next = node2; node2.prev=node1;当将node1=null;node2=null后也不能回收)
  • **可达性分析算法:**以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点。。。(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。
    • 若不是以GCRoot为起点的引用,也会被判定为垃圾

1.3.3 GC root对象

  • 虚拟机栈中的引用对象;
  • 方法区中的类静态熟悉引用的对象,即static修饰的引用对象
  • 方法区中常量引用对象,即final修饰的引用对象
  • 本地方法栈中的对象,即native修饰的引用对象
  • 虚拟机内部的引用,如基本数据类型的class对象等
  • 所有被同步锁()持有的对象

1.3.4 finalize方法

finalize方法是object中的方法,如果该类重写了该方法,那么第一次对该对象回收时就会先执行finalize方法,若执行完finalize方法后对象为可达状态则不会进行回收。

注意该方法只能被执行一次,下一次再回收该对象时若finalize已经被执行一次,则直接回收。

1.3.5 垃圾回收算法

  • 标记清除算法
    • 有内存碎片
  • 标记复制算法
  • 标记整理算法
  • 分代收集算法
    • 新生代:将新生代内存分为Eden、S0、S1区域,比例8:1:1。GC开始时,对象只会存在于Eden区域和From Survivor区域,GC进行时,eden区域存活的对象会复制到To Survivor区域,而From Survivor区域存活的对象会根据存活年龄分配去处,年龄大的分配到老年代,否则去To Survivor。
    • **老年代:**采用标记清除或标记整理算法

1.3.6 何时晋升到老年代

  • 对象的年龄到达JVM限定的年龄
  • 创建一个大对象的时候
  • S0或S1区域的相同年龄的对象的大小之和大于S区域的一半时,会将年龄大于该值的对象移入老年代。

1.3.7 Minor GC和Full GC触发条件

  • Minor GC触发条件
    • 当Eden区域满的时候
  • Full GC触发条件
    • 老年代空间不足
    • 老年代最大可用连续空间小于新生代所有对象的总空间且虚拟机不允许担保策略时会发生full GC;
    • 若允许担保策略,则老年代最大可用连续空间小于历次晋升到老年代对象的平均大小时fullGC

1.3.8 safe point(安全点)

安全点:决定了用户程序执行时并非在代码任意位置都可以停下来进行垃圾回收,而是强制要求必须到达安全点后才能暂停用户线程进行垃圾回收。

安全点的选取原则:是否具有让程序长时间执行的特征。

  • 方法调用
  • 循环末尾
  • 抛出异常

如何保证所有线程都在安全点上?

  • 抢先式中断:垃圾回收时发现有线程不在安全点,则让该线程继续执行到安全点。
  • 主动式中断:

1.4 垃圾回收器

垃圾回收主要有四大思想:串行、并行、并发、G1。

查看默认的垃圾回收器

java -XX:+PrintCommandLineFlags -version

image-20200603111605087

所有的垃圾回收器

image-20200603112712423

新生代默认都是标记复制算法。

部分参数

image-20200603113208659

1.4.1 Serial垃圾收集器

单线程的收集器,用于新生代,在垃圾收集的时候必须暂停所有的工作线程直到收集结束。

该收集器现在一般用於单核cpu。开启后老年代也会默认使用串行的收集器Serial Old

-XX:+UseSerialGC   # 开启新生代使用串行收集器,开启后老年代也会默认使用串行的收集器

1.4.2 ParNew收集器

多线程进行垃圾回收,用于新生代,在垃圾收集的时候必须暂停所有的工作线程直到收集结束。一般新生代使用ParNew配合老年代CMS使用。

若新生代开启UseParNew,默认此时老年代是serialOld,会警告不被推荐使用。需再配置老年代收集器。

-XX:+UseParNewGC  # 启用ParNew收集器,只影响新生代,不影响老年代。

-XX:ParNewGCThreads   #限制线程数量,默认是CPU数量相同的线程数

1.4.3 Parallel Scavenge收集器

Java8默认的收集器。

多线程进行垃圾回收,用于新生代。在垃圾收集的时候必须暂停所有的工作线程直到收集结束。开启后会默认老年代也是用并行的垃圾收集器

这个并行收集器关注的是吞吐量(运行用户代码时间/(用户时间+GC时间)

-XX:+UseParallelGC   #开启新生代Parallel Scavenge收集器,开启后老年代默认使用Parallel Old收集器

1.4.4 Serial Old收集器

Serial收集器的老年代版本,单线程收集器,标记整理算法暂停所有用户线程并单线程收集垃圾。

1.4.5 Parallel Old收集器

Parallel Scavenge的老年代版本,多线程并发收集。

-XX:UseParallelOldGC   #开启老年代代Parallel Old收集器,开启后新生代默认使用Parallel Scavenge收集器

1.4.6 CMS垃圾收集器

Concurrent Mark Sweep:并发标记清除

一种以获得最短回收停顿时间为目标的收集器,适用于互联网或B/S系统服务器。CMS一般只作用于老年代的收集,基于标记清除算法

-XX:+UseConcMarkSweepGC  #开启CMS,默认新生代会开启ParaNew

image-20200603150403533

四个步骤

  • 初始标记:只标记GC root能直接关联的对象,所以速度很快。需要暂停用户线程

  • 并发标记:可达性分析过程

  • 重新标记:修正并发标记期间因用户线程继续运行而导致标记产生变动的那一部分对象的标记记录,这个时间稍长于初始标记。需要暂停用户线程。

  • 并发清除:并发的清除垃圾

  • 优点:GC线程可以与用户线程并发执行,从而降低收集停顿时间

  • 缺点

    • CPU资源敏感。因为需要分出几个线程((处理器核心数+3)/4)行进回收,造成吞吐量小;(若cpu核心数少,性能影响大)
    • 有浮动垃圾。最后并发清除期间会产生垃圾不能回收,只能下次回收。
    • 内存碎片。标记清除算法固有缺点
-XX:CMSInitiatingOccu-pancyFraction  #代表CMS收集器的启动阈值百分比

在JDK6之后,默认CMS启动阈值时92%,即老年代空间达到92%时才会启动CMS收集器回收垃圾,

但是CMS垃圾收集器必须在老年代堆内存用尽之前完成垃圾收集,否则会造成失败,失败后后使用serial Old收集器备用收集,此时停顿时间将会很长;

所以不建议将该参数设置得非常大。

1.4.7 垃圾收集器的选择

  • 单cpu或者小内存单机程序
    • 使用serialGC即可 -XX:UseSerialGC
  • 多CPU,需求是最大吞吐量,如后台计算型应用
    • 使用ParallelGC即可。配置-XX:+UseParallelGC 或者 -XX:UseParallelOldGC
  • 多CPU,追求停顿时间短,交互性强的互联网应用
    • 使用ParNew+CMS组合

1.4.8 G1收集器

在实现高吞吐量的同时,尽量减少停顿时间。

  • 收集器特点:

    • 虽然也有新生代和老年代的概念,但不像CMS那样将新生代和老年代产生物理上的隔离,而是将堆划分成一个个区域,E区、S区、O区和H区域;H区域代表存储的是巨大对象;gc不必在全堆上进行。
    • G1收集器有内存整理过程,不会产生很多内存碎片。
    • 用户可以指定期望的停顿时间
    • image-20200603153637548
  • 回收细节:

    • G1 跟踪各个块里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的Region,也就避免了整个老年代的回收,也就减少了STW 造成的停顿时间。
  • 收集步骤

    • 初始标记:仅标记gcroots能直接到达的对象
    • 并发标记:可达性分析
    • 最终标记:暂停用户线程,做最终标记
    • 筛选回收:负责更新每个块的统计数据,然后对每个块的回收价值进行排序,根据用户期望停留时间选择哪些块进行回收。每个块进行回收时,采用复制算法

image-20200603154318205

  • 对于CMS的优势
    • 没有内存碎片
    • 能指定期望停顿时间

1.4.9 垃圾收集器总结

image-20200603152515975

JVM参数在启动微服务时配置,如启动一个订单微服务模块

java -server -Xms1024m -Xmx1024m -XX:+UseG1GC -war server-order.war
# 设置初始和最大堆内存1G,并采用G1垃圾回收器

1.5 类加载机制

1.5.1 类加载过程

  • ​ 什么是类加载机制?
    • 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型, 这就是虚拟机的类加载机制。
  • 类加载过程?
    • 加载:将class文件读入内存并创建java.lang.Class对象
    • 验证:验证class格式、语法定义等
    • 准备:为static静态域默认初始化,并分配内存空间。
    • 解析:将符号引用替换成直接引用
    • 初始化:根据程序初始化类变量和其他资源

1.5.2 类加载机制

  • 全盘负责机制:当一个加载器加载某个Class时,该Class依赖的Class也由该加载器加载。

  • **双亲委派机制:**首先采用该类的父类加载器去加载,失败时才自己去加载。

  • ​ 类加载器有哪些?

    • 启动类加载器:加载java核心类,C++编写。加载lJAVA_HOME/lib目录
    • 扩展类加载器:加载扩展类,JAVA_HOME/lib/ext目录
    • 应用程序类加载器:加载用户类路径上所有类库。
  • 双亲委派模型加载过程?

    • 所有类的加载都会先去委托父级类加载器加载,所以无论哪一个类加载器要加载一个类,最终都会委托最顶端的启动类加载器去加载,只有加载失败时才会轮到下一级加载器加载。
    • 双亲委派模型也保证了在自己的classPath上写一个java.lang.Object类不会被加载,因为最终会委托到启动类加载器加载,此时加载的是lib目录下的类。

1.5.3 双亲委派模型的破坏及Tomcat的类加载机制

网易面试时的问题:在Tomcat里面部署两个应用程序,但是两个程序使用不同的spring版本,怎么实现的?

其实这里是破坏了双亲委派模型规则的,这个规则本身也不是强制的。到目前为止,双亲委派模型出现过几次大规模破坏:

  • SPI破坏双亲委派:Java 在核心类库中定义了许多接口,并且还给出了针对这些接口的调用逻辑,然而并未给出实现。开发者要做的就是定制一个实现类,以供核心类库使用。

    java.sql.Driver 是最为典型的 SPI 接口。java.sql.DriverManager 通过扫包的方式拿到指定的实现类,完成 DriverManager的初始化。

    而根据双亲委派模型,启动类加载器 加载的 DriverManager 是不可能拿到 系统应用类加载器 加载的实现类 ,这似乎通过某种机制打破了双亲委派模型。通过 Class.forName 配合 classloader拿到。

  • 热部署破坏双亲委派:

回到面试题,考点就是Tomcat的类加载机制:

img

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;

  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;

  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;

  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见;

tomcat 为了实现隔离性,没有遵守双亲委派,每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。

JSP的类加载机制: tomcat 是支持JSP 热部署的 ,jsp 文件其实也就是class文件,那么如果修改了,但类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。那么怎么办呢?我们可以直接卸载掉这jsp文件的类加载器,所以你应该想到了,每个jsp文件对应一个唯一的类加载器,当一个jsp文件修改了,就直接卸载这个jsp类加载器。重新创建类加载器,重新加载jsp文件, 所以会需要一个JSP classLoader。

1.5.4 类初始化与实例初始化

经典面试题如下:

image-20200605100650006

运行结果如下:

image-20200605095448738

  • 三个考点:
    • 类的初始化(Son执行main方法):父类的初始化先于子类的初始化,在Son类中要执行main方法就要初始化Son类,初始化Son类就要先初始化Father类。类初始化顺序:按照代码书写顺序执行,在上面代码中为:静态变量的初始化 > 静态代码块的初始化。如果交换位置,则顺序相反。
      • 初始化类的打印结果:(5)(1)(10)(6)
    • 实例的初始化(new Son()):父类的构造先于子类的构造,且注意父类方法的重写问题。非静态变量与非静态代码块的执行顺序与代码书写顺序有关。
      • 子类的实例化:super() > (子类的非静态变量初始化 与 子类的非静态构造块) > 子类的构造器。super指的是父类的构造的全部。
      • 父类的构造:super() > (父类的非静态变量初始化 与 父类的非静态构造块) > 父类的构造器
      • 执行顺序如下:
        • 父类Father的非静态变量初始化,即test方法,由于重写了方法,所以打印(9)
        • 父类的非静态构造块:(3)
        • 父类的构造器:(2)
        • 子类的非静态变量初始化,test方法:(9)
        • 子类的非静态构造器:(8)
        • 子类的构造器:(7)
    • 重写方法
      • final方法,静态方法,private方法不会被重写

1.6 四种引用方式

image-20200603085747032

  • 强引用:在有引用的情况下,JVM宁愿OOM也不愿回收;
  • 软引用(SoftReference):是否回收取决于内存是否足够,不足时就回收;如mybatis高速缓存就用了一些软引用。
  • 弱引用(WeakReference):不论内存是否充足都会被回收;
  • 虚引用:任何时候都可能被回收
    public static void main(String[] args) {
        Object obj1 = new Object();
//        Object obj2 = obj1;   //强引用
//        SoftReference<Object> obj3 = new SoftReference<>(obj1);  //软引用
        WeakReference<Object> obj4 = new WeakReference<>(obj1);    //弱引用
        obj1 = null;  
        System.gc();  //此时只有obj4弱引用指向Object对象,会被回收。
//        System.out.println(obj2);
//        System.out.println(obj3.get());
        System.out.println(obj4.get());
    }

场景:应用需要读取大量的本地图片,若每次都从磁盘读取性能不佳,若全部放内存又可能造成OOM,如何解决?

将读取的图片对象读取为软引用或者弱引用,即将对象缓存到内存,当内存不足时进行垃圾回收;

使用一个Map<String,SoftReference> 存储。

1.6.1 WeakHashMap

key不是强引用,当没有引用指向key对象时,GC会进行回收。

public static void main(String[] args) {
    WeakHashMap<Integer, String> map = new WeakHashMap<>();
    Integer key = new Integer(1);
    map.put(key, "map");

    key = null;
    System.gc();
    System.out.println(map);
}

但是Value是强引用,不会进行垃圾回收;

public static void main(String[] args) {
    WeakHashMap<Integer, Object> map = new WeakHashMap<>();
    Object value = new Object();
    Integer key = new Integer(1);
    map.put(key, value);

    value = null;
    System.gc();
    System.out.println(map);
}

1.6.2 引用队列ReferenceQueue

引用队列的作用是,当软(弱)引用进行垃圾回收时,将软(弱)引用对象放到引用队列中。

public static void main(String[] args) {
    Object o = new Object();
    ReferenceQueue<Object> referenceQueue = new ReferenceQueue<>();  //引用队列
    WeakReference<Object> weakReference = new WeakReference<>(o, referenceQueue);  //弱引用对象

    System.out.println("############垃圾回收前##########");
    System.out.println(o);
    System.out.println(weakReference);
    System.out.println(weakReference.get());
    System.out.println(referenceQueue.poll());

    o = null;
    System.gc();

    System.out.println("############垃圾回收后##########");
    System.out.println(o);
    System.out.println(weakReference.get());
    System.out.println(referenceQueue.poll());
}

image-20200603095930029

引用队列一般与虚引用配合使用,用于在finalize方法之中。

1.6.3 垃圾回收总结

从GCRoot开始可达性分析算法,若某对象被强引用,则即使OOM也不会去垃圾回收;

若某对象不被强引用,而被软引用,则内存情况而定,内存不足则回收

若对象只被弱引用,则一定会被回收。

若某对象不可达,则回收。

(以上情况不考虑finalize方法的影响)

image-20200603100600459

1.7 虚拟机性能监控、故障处理

1.7.1 虚拟机故障处理工具

在JDK的bin目录中,有java.exe、javac.exe,用于执行和编译,除此之外还有许多其他命令用于打包、部署、调试、监控、运维等场景。

  • jps:虚拟机进程状态工具。可以查看虚拟机中的所有进程。

    C:\Users\me>jps
    40448 RemoteMavenServer36
    38292 JseckillBackendApp
    38756 Launcher
    52292
    34076 RemoteJdbcServer
    54012 Jps
    
  • jstat:虚拟机统计信息监视工具

    jstat -gc 2764 250 20 :查询进程2764的gc信息,每个250毫秒查询一次,共查询20次。

    jstat -class 3872 :查询3872的类加载信息。

    -gcutil :监视内容与gc相同,输出的是百分比信息。

    C:\Users\me>jstat -gcutil 38292
      S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT
    0.00  93.05  56.62  12.05  94.96  93.38      7    0.052     2    0.061    0.113
    
    s0为空
    s1已用93%
    eden已用56%
    old老年代已用12%
    youngGC 7次  用时0.052秒
    fullGC 2次  用时0.06秒
    总共GC用时0.113秒
    
  • jinfo:Java配置信息工具

    • 作用:实时查看和调整JVM虚拟机各项参数。查看某个进程的某个JVM参数

    • E:\JavaProject\Kaoshi>jinfo -flag PrintGCDetails 18164
      -XX:-PrintGCDetails   # -xx:  表示该参数为boolean类型;  -PrintGCDetails表示未开启
      
    • image-20200602151614085

1.7.2 查看JVM的参数

查看当前进程的JVM参数

jinfo -falgs 进程pid

image-20200602152342048

  • Non-default VM flags: 表示系统默认参数
  • Command line: 表示自己设置的参数
-XX:InitialHeapSize=268435456  等价于-Xms  表示初始堆内存
-XX:MaxHeapSize=4280287232    等价于-Xmx  表示最大堆内存

查看JVM所有初始参数

java -XX:+PrintFlagsInitial

=表示JVM默认值 :=表示修改过的值

查看使用的垃圾回收器

java -XX:+PrintCommandLineFlags -version

image-20200602171725905

可以看到默认使用的是UseParallelGC,并行垃圾回收器。

1.7.3 基本常用参数

image-20200602172140776

# 堆栈大小
Xms:初始堆内存大小,默认物理内存1/64。等价于  -XX:InitialHeapSize
Xmx:最大堆内存大小,默认物理内存1/4。等价于    -XX:MaxHeapSize
Xss:运行时栈空间,默认初始值1024Kb(Linux系统下)。等价于XX:ThreadStackSize
# 年轻代和元空间大小
Xmn:#年轻代大小

#元空间大小(元空间不放在虚拟机中,而是使用本地内存),这个可以设置大一些
-XX:MetaspaceSize   
-XX:+PrintGCDetails   #打印出垃圾回收信息

测试参数:将最大堆内存和初始堆内存改为10m,并加上-XX:+PrintGCDetails ,new一个10m的byte数组,将看到打印信息如下

image-20200602174641752

从打印信息中可以看到GC和FullGC前后内存大小的变化

-XX:+SurvivorRatio    #Eden区和S区的比例。默认值为8,代表比例8:1:1

-XX:NewRatio    #年轻代和老年代在堆结构上的占比。默认为2,即1:2;

-XX:MaxTenuringThreshold    # 进入老年代的年龄阈值(Java8最大设置为15,也是默认15)

1.8 常见JVM层面的报错OOM

image-20200603103537864

1.8.1 StackOverflowError

在做遍历有向图的所有环时,出现过栈溢出;出现原因就是递归太深!!

在适当调大-Xss后还是不能解决,由于题目限制只找出环数量小于7的环,所以限制递归。

1.8.2 OOM java heap space

堆内存溢出,出现原因堆满了

1.8.3 OOM GCOverHead

堆内存快满了发生GC,但是GC清除了2%不到,多次这种情况就会报错。因为绝大多数时间都在GC

1.8.4 OOM:Direct buffer member

Java的元空间内存时分配在虚拟机之外的,不受java虚拟机内存限制。

写INO程序时可以直接使用Native函数库直接分配堆外内存,然后通过Java堆中的DirectByteBuffer对象去引用堆外内存进行操作。

可能Java堆内存空间空余,而堆外内存已经满了,则会造成OOM:Direct buffer member。

1.8.5 OOM:Metaspace

元空间内存溢出。

  • 元空间主要存放以下信息:
    • 虚拟机加载类信息
    • 常量池
    • 静态变量
    • 编译后的代码

元空间虽然不受虚拟机内存限制,只受限于本地内存大小,但是初始参数设置为21M,有可能会造成内存溢出。

2 并发编程

2.1 什么是线程安全?

2.2 创建多线程的几种方式

  • 直接继承Thread类,重写run方法。
  • 实现Runnable接口,重写run方法,实现类作为参数传入Thread的构造方法。
  • 实现Callable接口,重写call方法,将实现类包装成一个FutureTask对象作为参数传入Thread的构造方法。优点是可以带有返回值,缺点是相对复杂。
  • 使用线程池创建线程

2.3 并发机制底层实现

2.3.1 synchronized关键字

synchronized关键字的作用

用于为Java对象、方法、代码块提供线程安全的操作,属于排它的悲观锁,也属于可重入锁。

synchronized关键字可作用于代码块、方法、静态方法。

  • 修饰实例方法:作用于当前对象实例加锁。锁的是this
  • 修饰静态方法:给当前类加锁。会作用于当前类的所有实例,因为静态成员不属于任何一个实例,是类成员。锁的是class
  • 修饰代码块:收到传入一个锁对象,锁传入的对象

**注意:**当线程A调用synchronized修饰的非静态方法,线程b调用synchronized修饰的静态方法是被允许的。因为非静态方法是使用实例对象的锁,静态方法是使用类的锁。

synchronized的实现原理

在JVM中,对象是分成三部分存在的:对象头、实例数据、对齐填充。对象头主要结构是由Mark WordClass Metadata Address组成,其中Mark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息,Mark Word结构如下:

img

当锁标记为10时为重量级锁,有一个指针指向一个Monitor对象,monitor里面有一些数据结构如锁竞争队列ContentionList、竞争候选列表(EntryList)、等待集合WaitSet分别保存想要获得锁的线程、在锁竞争队列中有资格获得锁的线程、调用wait方法后阻塞的线程(三个集合中的线程都为阻塞状态)。monitor中还有个Owner标识位表示当前哪个线程获得锁,用于互斥。

  1. **修饰代码块时:**通过monitor(监视器)机制实现。首先通过monitorEnter和monitorExit进入和退出临界区(monitorExit有两个位置,一个是正常退出,一个是异常时退出)。然后读取对象头中Mark work的标志位,判断锁的状态,是偏向锁还是轻量级锁还是重量级锁。若是偏向锁,则判断当前线程id是否等于记录的线程id,相等则直接运行。若轻量级锁,则通过自旋获得锁,自旋一定次数失败后转重量级锁。若重量级锁,则将线程加入monitor对象的的锁竞争队列里。
  2. 修饰方法时:对方是否加锁是通过一个标记位来判断的。

synchronized如何保证可见性

  • 线程解锁前,必须把共享变量的最新值刷新到主内存中
  • 线程获得锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值

synchronized锁的执行流程

  1. 首先是偏向锁;如果一个线程获得了锁,那么锁就进入偏向模式,当该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查锁标记位为偏向锁以及当前线程ID等于对象头中的ThreadID即可,这样就省去了大量有关锁申请的操作。
  2. 轻量级锁;当第二个线程申请锁且没有锁竞争时,就转为轻量级锁,使用CAS方式修改共享变量。
  3. **自旋锁;**若轻量级锁失败,线程不会立即释放cpu资源,而是进行自旋持续的获取锁。(注:这种方式明显造成了不公平现象,最后申请的线程可能获取锁)
  4. **重量级锁;**轻量级锁失败的线程放入锁竞争队列(阻塞态);

虽然synchronized有锁升级的过程,但是这个过程基本不可逆,所以还是推荐使用Lock锁

2.3.2 synchronized与Lock的区别联系

  1. synchronized是java的关键字,JVM实现,通过monitor实现;Lock锁是JUC并发包中的实现,基于AQS模板重写tryAcquire、tryRelease实现;
  2. synchronized可以修饰方法,而Lock只能用于代码块。
  3. synchronized会自动释放锁,而Lock需手动释放。
  4. Lock可以是公平锁也可以是非公平锁,而synchronized只能是非公平锁。
  5. synchronized不可中断,除非抛出异常或者正常执行完毕;ReentrantLock可中断,tryLock可以设置超时时间,lockInterruptibly()放入代码块中,调用interrupt()方法可以中断。
  6. ReentrantLock可以绑定多个Condition条件用于实现分组唤醒需要唤醒的线程,实现精确唤醒。而synchronized要么随机唤醒一个要么全部唤醒。
  7. 两者都是可重入锁。

2.3.3 volatile关键字

轻量级的同步机制,保证可见性和禁止指令重排保证有序性。不保证原子性。

  • volatile作用:保证可见性和禁止指令重排。Java把处理器的多级缓存抽象为JMM,即线程私有的工作内存和线程公有的主内存,每个线程从主内存拷贝所需数据到自己的工作内存。volatile的作用就是当线程修改被volatile修饰的变量时,要立即写入到主内存,并通知其他线程该变量已经修改,当线程读取被volatile修饰的变量时,要立即到主内存中去读取,保证了可见性。禁止指令重排来保证顺序性。(单例模式的双重校验最好是加上volatile关键字,防止指令重排)
  • 可见性的实现:每个线程从主内存拷贝所需数据到自己的工作内存。volatile的作用就是当线程修改被volatile修饰的变量时,要立即写入到主内存,并通知其他线程该变量已经修改,当线程读取被volatile修饰的变量时,要立即到主内存中去读取,保证了可见性。
  • **Volatile实现原理:**①JVM向处理器发送一条LOCK指令,表示将这个变量的缓存行的数据写回到内存。②一个处理器的缓存行写回到内存会导致其他处理器的缓存无效(缓存一致性协议:处理器会嗅探总线上的传播数据来判断自己缓存的数据是否过期)。

总线风暴?

因为缓存一致性原理和CAS循环导致总线无效的交互太多,总线带宽达到峰值。

为什么volatile不保证原子性

javap查看字节码文件,如对一个volatile关键字修饰的变量n执行n++操作的指令是

getfield
iadd
putfield

指令被拆成了三个,那么多线程对一个数据进行修改时,会出现写覆盖的情况。当某个线程执行到getfield指令之后被挂起,那么该线程将获取不到其他线程修改后的最新数据。

如何保证volatile的原子性

如有n++等操作可以使用atomic类如atomicInteger。(atomic原理CAS)

指令重排的原理

指令重排:在保证数据依赖性的情况下,编译器优化可能对指令进行重排,指令重排在单线程情况下不会有任何问题。

在单线程情况下没有依赖性的数据在多线程情况下就可能有依赖性,就会出现问题。所以volatile关键字会禁止指令重排。

在哪里用到volatile

单例模式会用到。双重校验单例模式+volatile禁止指令重排

public class Main {
	private volatile Main instance = null;  //volatile关键字禁止指令重排,防止多线程情况下instance不为null但是还未初始化完成的情况出现。
    private Main(){
        System.out.println("执行构造函数");
    }
    
    public Main getInstance(){
    //双重检验
        if(instance == null){
            synchronized (Main.class){
                if(instance == null){
                    instance = new Main();
                }
            }
        }
        return instance;
    }
}

2.3.4 atomic包和CAS原理及问题

AtomicInteger中的自增操作详解

AtomicInteger类的定义

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

可见整个value使用volatile关键字修饰,保证可见性和禁止指令重排。

再看自增函数的实现

public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    //this代表当前对象,valueoffset代表value这个值的内存偏移量,1代表要加1操作。

找到unsafe类的实现。do while循环自旋锁实现

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;  //声明修改前的值
    do {
    	var5 = this.getIntVolatile(var1, var2);  //本地方法,根据对象和内存偏移量获取值。
    } 
    //自旋锁本地方法实现比较并交换。val1:当前对象  val2:位移偏移量  val5:修改前的值  val5+val4:修改后的值
    //根据val1和val2获取当前值,与val5比较,若相等则将该值赋值为val5+val4
    //compareAndSwapInt方法是利用cpu原语实现,不可中断保证原子性。
    while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}
  • 处理器解决原子操作:

    • 总线锁:处理器提供一个LOCK #信号,当一个处理器在总线上输出此信号时,其他处理器请求将被阻塞。(缺点:其他处理器也不能操作其他内存,开销大)
    • 缓存锁:通过缓存锁定实现。
    • 处理器提供一系列指令实现总线锁和缓存锁两个机制,CMPXCHG指令用于实现Java的CAS操作。
  • CAS的三大问题:

    • ABA问题。Atomic包中有一个类可以解决这个问题
    • 循环时间长时CPU开销大(并发太高的情况下不适用)
    • 只能保证一个共享变量的原子操作;
  • ABA问题的解决:时间戳的原子引用

    AtomicReference类

    在atomic包中有基本的原子类实现,如果需要实现自己写的User类的原子操作就需要使用AtomicReference加泛型实现。

    class User{
        String username;
        int age;
        User(String name, int age){
            this.username = name;
            this.age = age;
        }
    
        @Override
        public String toString() {
            return username+"  "+age;
        }
    }
    public class Main {
        public static void main(String[] args) {
            User u1 = new User("aa", 18);
            User u2 = new User("bb", 20);
            AtomicReference<User> userAtomicReference = new AtomicReference<User>(u1);
            System.out.println(userAtomicReference.compareAndSet(u1, u2));
            System.out.println(userAtomicReference.get());
        }
    }
    

加上修改版本号解决ABA问题

在JUC包中有一个AtomicStampedReference类已经可以实现带版本号的原子引用。

new AtomicStampedReference<User>(u1,1);  //u1为初始值,1为初始版本号。以后每次修改版本号加一

原理:类中是一个Pair类作为数据结构解决ABA问题,修改后版本后不一样。

    private static class Pair<T> {
        final T reference;  //我们的数据
        final int stamp;   //版本号
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }

    private volatile Pair<V> pair;   //最主要的数据,每次比较这个值

2.4 Java并发容器

2.4.1 List集合的线程安全

    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        for(int i=0;i<10;++i){
            new Thread(()->{
                list.add(UUID.randomUUID().toString().substring(0,8));
                System.out.println(list);
            }).start();
        }
    }

当多线程对共享变量list添加数据时,会发生线程安全问题,上述代码可能报错如下:

Exception in thread "Thread-0" java.util.ConcurrentModificationException
//多线程修改异常

解决方案:

  • vector

  • List<String> list = Collections.synchronizedList(new ArrayList<>());
    
  • CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
    

CopyOnWriteArrayList详解

将Object[]数组采用volatile关键字保证可见性和有序性,然后提供get和set方法。

private transient volatile Object[] array;

final Object[] getArray() {
    return array;
}

final void setArray(Object[] a) {
    array = a;
}

在add方法时copyonwrite,写完后替换数组,这样不影响读操作。

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        Object[] elements = getArray();
        int len = elements.length;
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        newElements[len] = e;
        setArray(newElements);
        return true;
    } finally {
        lock.unlock();
    }
}

读操作时没有任何的锁限制,这样读写分离的操作提高了并发。

2.4.2 Set集合类的线程安全

有一个CopyOnWriteArraySet可以实现,底层是利用CopyOnWriteArrayList实现,但是list是基于数组实现,在插入数据时会遍历数组判断数据是否存在,复杂度高,所以这个基本不会使用。

利用new ConcurrentHashMap<>()实现线程安全的set。map中的key存值,value统一存同一个final object。

2.4.3 Map的线程安全

HashMap结构及存在的问题

HashMap结构及1.7和1.8变化

HashMap在1.7的时候使用数组加链表实现,它的数据结点是一个内部类Entry结点,且采用头插法作为哈希冲突的链表插入。头插法在多线程扩容中可能造成死循环的问题。Java在1.8之后做了一定的优化。

HashMap在1.8之后采用数组加链表加红黑树的结构实现,数据结点是一个Node结点。当有哈希冲突后会采用尾插入的方式加入链表,当链表长度大于8且数组长度大于64时会转为红黑树结构,当红黑树的结点数量小于等于6时重新转成链表。

死循环如何产生?

多线程时扩容的死循环问题:若某个inde处的链表为:A—B—null,若两个线程同时对这里扩容,线程一在获取A这个Node后被挂起,线程二执行扩容并完成,此时新数组的链表为B—A—null;线程一继续执行,线程一种的ANode的next还是指向B的,而获取BNode后B又是指向A的,从此ABABAB死循环。主要造成原因还是因为JDK1.7中头插法造成。JDK1.8之后采用尾插,不会有此问题。

多线程同时put数据造成数据丢失覆盖:两个线程同时对一个index添加数据,可能会有覆盖丢失问题。

扩容机制

HashMap的初始数组大小是16,负载因子为0.75,当数组的大小大于阈值(数组长度*负载因子)的时候会进行扩容,扩容时新建一个2倍大小的数组,然后根据哈希算法重新计算索引然后进行插入。

哈希冲突还有哪些解决方案?

  • 开放地址法:即在数组中通过规则找到其他位置存放
  • 链表法
  • 再哈希法:多个哈希算法,第一个冲突使用第二个,第二个冲突使用第三个…

ConcurrentHashMap整体结构

  • Node数组:存放键值对的数组,最重要的存放数据的数组。

    • transient volatile Node<K,V>[] table;
      
  • put方法:添加元素的主要方法。putVal(key, value)

    • 第一步:判断key和value是否有null,若有则报错。
    • 第二步:一个死循环包含整个添加元素的代码块。for (Node<K,V>[] tab = table;😉 {添加元素的代码块}
    • 第三步:判断数组是否初始化,若没有则执行初始化后回到第二步。
    • 第四步:根据hash值计算出索引,若索引处为null,则CAS方式写入,若失败则回到步骤二,成功则跳出死循环;
    • 第五步:判断是否正在扩容,若是就帮助扩容。
    • 第六步:2345步骤都不成立时,都对index处的Node加synchronized锁。分别针对链表和红黑树两种情况进行写入。
    • 第七步:调用addCount函数,即对size+1;
    • 注:23456几个步骤之间是if else关系,一次循环只会执行一个。
  • 如何对map大小进行计数(addCount)?

    • concurrentHashMap里面有一个long baseCount和 CounterCell数组counterCells。CounterCell其实就是一个计数器功能的类(添加了一个注解解决伪共享带来的性能消耗)。
    • 当put完元素后,首先判断CounterCells这个数组是否为null,若为null,则尝试采用CAS方式写入,若数组不为null或写入失败,则操作CounterCells数组。
    • 操作CounterCells数组方式:首先确认CounterCells不为空后,通过该线程获取一个随机数然后与CounterCells数组的length-1进行位与操作,得到一个数组下标。若数组下标不为null,则对下标的对象采用CAS进行计数器加一操作。若CAS失败,调用fullAddCount方法;fullAddCount中再CAS一次,若失败则将CounterCell数组扩容再CAS。
  • 如何获取map的size

    就是将baseCount和CounterCell数组中的计数值。

    final long sumCount() {
            CounterCell[] as = counterCells; CounterCell a;
            long sum = baseCount;
            if (as != null) {
                for (int i = 0; i < as.length; ++i) {
                    if ((a = as[i]) != null)
                        sum += a.value;
                }
            }
            return sum;
        }
    
  • map扩容原理

    • 什么时候扩容:①当put成功后链表长度大于8需要将其转换成红黑树之前,判断map的size是否小于64,若小于64则扩容(也就意味着数组size小于64的话不会存在红黑树); ②put成功后size大小大于数组大小的0.75倍。
    • 多线程扩容基本实现原理:每个线程都从后往前进行遍历,一个index只能有一个线程进行扩容,若已经扩容完成或正在扩容,将该index头结点设置为Node的一个子类(ForwardingNode)。新数组每个index只能有一个线程进行操作,若不为null,synchronized关键字锁住链表头Node。

2.4.4 CountDownLatch(倒计数)

用于一个或多个线程等待其他线程完成。

需求:在主线程中开启多个线程去执行特定任务,在所有任务执行完成后主线程中打印所有任务执行完毕。

方案一:使用join()方法,让开启的线程加入join方法,意味着执行线程即main线程等待join线程执行完毕。这种方法会使多线程顺序执行

    public static void main(String[] args) throws Exception {
        for(int i=0; i<10; ++i){
            final int temp_i = i;
            Thread thread = new Thread(() -> {
                try {
                    System.out.println("线程" + temp_i + "执行");
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
            thread.join();
        }
        System.out.println("所有线程执行完毕");
    }

方案二:使用CountDownLatch实现。

    public static void main(String[] args) throws Exception {
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for(int i=0; i<10; ++i){
            final int temp_i = i;
             new Thread(() -> {
                System.out.print(Thread.currentThread().getName());
                System.out.println("线程" + temp_i + "执行");
                countDownLatch.countDown();  //这里countDownLatch中的值减一
            }).start();
        }
        countDownLatch.await();  //在这里等待所有线程执行完毕,即值变为0
        System.out.println("所有线程执行完毕");
    }

2.4.5 CyclicBarrier(计数器)

与CountDownLatch相反,CyclicBarrier需达到多少数据才会执行某线程。一个做加法,一个做减法。

public static void main(String[] args) throws Exception {
    CyclicBarrier cyclicBarrier = new CyclicBarrier(5, ()->{
        System.out.println("所有线程执行完毕");
    });
    for(int i=0; i<10; ++i){
        final int temp_i = i;
         new Thread(() -> {
             try {
                 System.out.print(Thread.currentThread().getName());
                 System.out.println("线程" + temp_i + "执行");
                 cyclicBarrier.await();
             } catch (Exception e) {
                 e.printStackTrace();
             }
         }).start();
    }

}

2.4.6 Semaphore信号量

信号量主要用于两个目的:多个共享资源的互斥使用;并发线程数控制;

使用semaphore控制并发数,只能允许5个线程去执行,当有线程执行完毕后其他线程才能进去执行。

public static void main(String[] args) throws Exception {
    Semaphore semaphore = new Semaphore(5);  //5代表只能有5个线程执行
    for(int i=0; i<10; ++i){
         new Thread(() -> {
             try {
                 semaphore.acquire();  //信号量加一
                 System.out.println(Thread.currentThread().getName()+"执行");
                 Thread.sleep(3000);
                 System.out.println(Thread.currentThread().getName()+"执行完毕");
             } catch (Exception e) {
                 e.printStackTrace();
             }finally {
                 semaphore.release();  //信号量减一
             }
         }).start();
    }
}

2.4.7 阻塞队列

阻塞队列的作用是:当队列满了,生产者线程将阻塞,当队列为空,消费者线程阻塞,避免去使用wait、notify的复杂操作。

阻塞队列的接口:BlockingQueue(阻塞队列)、BlockingDeque(双端阻塞队列)。

阻塞队列的实现类:image-20200601154346454

基本原理:使用Lock锁和condition实现锁和阻塞唤醒线程,count为队列数量,当队列满了则阻塞offer等加入队列的方法,当队列为空则阻塞poll等方法。

public class ArrayBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
    final Object[] items;

    int takeIndex;

    int putIndex;

    int count;

    final ReentrantLock lock;

    //两个condition精准唤醒线程,而不是唤醒所有线程
    private final Condition notEmpty;

    private final Condition notFull;
}

1. ArrayBlockingQueue

三种往阻塞队列中添加和删除元素的方法:

  • 添加元素
    • add(): 往队列中添加元素,若队列满了则抛出异常
    • offer(): 往队列中添加元素,若队列满了则返回false,可以传参指定等待时间
    • put():往队列中添加元素,若队列满了则阻塞线程直到队列不是满的状态时,加入队列
  • 删除元素
    • remove:队列为空时报错
    • poll:队列为空时返回null,可以传参指定等待时间
    • take:队列为空时阻塞线程
public static void main(String[] args) throws Exception {
    BlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(3);
    blockingQueue.add("a");
    blockingQueue.offer("b");
    blockingQueue.put("c");

    String remove = blockingQueue.remove();
    String poll = blockingQueue.poll();
    String take = blockingQueue.take();
}

2. SynchronousQueue

不存储元素,可以理解成单个元素的阻塞队列

public static void main(String[] args) throws Exception {
    BlockingQueue<Integer> queue = new SynchronousQueue<>();

//生产者线程
    new Thread(()->{
        for(int i=0;i<10;++i){
            try {
                System.out.println("生产者生产了"+i);
                queue.put(i);
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();

//消费者线程
    new Thread(()->{
        for(int i=0;i<10;++i){
            try {
                System.out.println("生产者消费了"+i);
                queue.take();
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }).start();

}

2.4.2 AQS详解

Java并发包(JUC)中提供了很多并发工具,这其中,很多我们耳熟能详的并发工具,譬如ReentrangLock、Semaphore,它们的实现都用到了一个共同的基类–AbstractQueuedSynchronizer,简称AQS。

AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLockSemaphore,其他的诸如ReentrantReadWriteLockSynchronousQueueFutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

AQS维护了一个state变量,来表示同步器的状态,state可以称为AQS的灵魂,基于AQS实现的好多JUC工具,都是通过操作state来实现的,state为0表示没有任何线程持有锁;state为1表示某一个线程拿到了一次锁,state为n(n > 1),表示这个线程获取了n次这把锁,用来表达所谓的“可重入锁”的概念。

  • AQS主要成员变量及数据结构

    • private transient volatile Node head; //队列头(队列用双向链表实现)
    • private transient volatile Node tail; //队列尾
    • private volatile int state; //共享变量表示同步状态
    • Node结构:双向链表的节点。AQS将每一条请求共享资源的线程封装成队列的一个结点(Node),来实现锁的分配。
  • 独占锁和共享锁

    • 独占,只有一个线程能获得锁,如ReentrantLock
    • 共享,多个线程可以同时获得锁,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier
  • 实现AQS需要重写哪些方法?

    • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它,如ReenTrantLock。
    • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
    • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
    • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
    • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
    • 注意,以上方法不是抽象方法,而是空实现。这种方法在模板设计模式中又叫钩子方法。
  • AQS执行流程图

ReentrantLock

公平锁和非公平锁如何实现?

以ReentrantLock的实现为例!

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    //判断当前锁是否被线程占有
    if (c == 0) {  
        //若没有线程拥有锁,则直接CAS获取锁(非公平的体现)
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //判断占有锁的线程是否是当前线程,若是当前线程也会获取锁(重入锁的体现)
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        //若没有线程拥有锁,先判断队列中没有线程等待获取锁时才会直接CAS获取锁
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

独占锁和共享锁如何实现

独占锁的体现:ReentrantLock为例,只有当stata==0 或者获取当前锁的线程为当前线程时才能获取锁,即锁只能有一个线程拥有。

共享锁的体现:stata变量可以为

2.4.3 ReenTrantLock详解

ReenTrantLock类基于AQS实现,主要的成员变量private final Sync sync; Sync类为继承AQS抽象类并实现tryAcquire、tryRelease、isHeldExclusively方法的抽象子类,提供了lock等抽象方法。

sync又有非公平锁和公平锁两个实现。两者主要是在tryAcquire()方法的重写上有不一样的实现。

Condition获取:final ConditionObject newCondition() { return new ConditionObject();}

ConditionObject类为ReenTrantLock的内部类,主要作用就是实现await()/signal()功能,相当于synchronized的wait和notify,用于挂起线程和唤醒线程。

2.5 锁

2.5.1 公平锁和非公平锁

2.5.2 可重入锁

可重入锁是指任意线程在获取到锁之后能够再次获取该锁而不会被阻塞。

2.5.3 自旋锁

2.5.4 读写锁

读写锁是一对锁,一个读锁和一个写锁,通过分离读写锁使得并发性比一般的排它锁有了很大提升。

读写锁在同一时刻可以允许多个线程访问,但是在写线程访问时,所有读写线程均被阻塞。

class MyCache{
    private volatile Map<String, Object> map = new HashMap<>();
    private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();  
    private ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock(); //写锁
    private ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();  //读锁


    public void put(String key,Object value){
        writeLock.lock();  //加写锁
        try {
            map.put(key,value);
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            writeLock.unlock();
        }
    }

    public Object get(String key){
        readLock.lock();
        try {
            return map.get(key);
        }finally {
            readLock.unlock();
        }
    }
}

2.6 生产者消费者的几种实现

2.6.1 lock锁实现

class SharaData{
    private int num = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();
    //生产
    public void increment(){
        lock.lock();
        try {
            //必须是while判断,否则可能出现虚假唤醒。在多个生产者消费者情况下会出现问题
            while (num!=0){  
                condition.await();
            }
            num++;
            System.out.println(Thread.currentThread().getName()+"生产");
            condition.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public void decrement(){
        lock.lock();
        try {
            while(num==0){
                condition.await();
            }
            num--;
            System.out.println(Thread.currentThread().getName()+"消费");
            condition.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        SharaData sharaData = new SharaData();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                sharaData.increment();
            }
        }).start();

        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                sharaData.decrement();
            }
        }).start();
    }
}

2.6.2 阻塞队列实现

class ShareData{
    private volatile Boolean flag = true;
    private BlockingQueue<String> blockingQueue = null;
    private AtomicInteger num = new AtomicInteger(0);

    public ShareData(BlockingQueue blockingQueue){
        this.blockingQueue = blockingQueue;
    }

    //生产者
    public void product() throws InterruptedException {
        String data = null;
        boolean offer;
        while(flag){
            data = "产品"+num;
            num.incrementAndGet();
            offer = blockingQueue.offer(data, 1, TimeUnit.SECONDS);
            if(offer){
                System.out.println("生产"+data);
            }else {
                System.out.println("生产"+data+"失败");
            }
            Thread.sleep(500);
        }
    }

    //消费者
    public void consume() throws InterruptedException {
        String poll = null;
        while(flag){
            poll = blockingQueue.poll(2, TimeUnit.SECONDS);
            if(null == poll || "".equals(poll)){
                System.out.println("消费失败");
            }else {
                System.out.println("消费"+poll);
            }
            Thread.sleep(700);
        }
    }

    //停止生产和消费
    public void stop(){
        flag = false;
    }
}
public static void main(String[] args) {
    ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(3);
    ShareData shareData = new ShareData(queue);

    new Thread(()->{
        try {
            shareData.product();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    new Thread(()->{
        try {
            shareData.consume();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();

    try {
        Thread.sleep(5000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    shareData.stop();
    System.out.println("停止生产和消费");
}

2.7 线程池详解

2.7.1 ThreadPoolExecutor创建线程池

  • 构造函数及其参数意义
public ThreadPoolExecutor(int corePoolSize,   //核心线程数
        int maximumPoolSize,   //最大线程数
        long keepAliveTime,    //非核心线程空闲存活时间
        TimeUnit unit,         //时间单位
        BlockingQueue<Runnable> workQueue,   //阻塞队列
        ThreadFactory threadFactory,   //线程工厂,用于创建新线程
        RejectedExecutionHandler handler)   //当达到最大线程数时的执行策略
  • 执行流程

img

  • 最大线程数的执行策略有哪些?
    • 抛出异常(默认)、什么都不做、抛弃最老的任务来执行、使用当前线程(如main)来执行。

自定义一个线程池:

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        5,  //核心线程数
        10,  //最大线程数
        1,  //非核心线程存活时间
        TimeUnit.SECONDS,
        new LinkedBlockingDeque<Runnable>(20),  //阻塞队列,注意一定要指定大小。
        Executors.defaultThreadFactory(),     //采用默认的线程创建工厂
        new ThreadPoolExecutor.AbortPolicy()  //拒绝策略
);

如何合理设置线程池参数:

  1. 看一下自己的cpu核心数Runtime类查看。
  2. 如果是CPU密集型:cpu核心数+1
  3. IO密集型:2*cpu核心数

2.7.2 使用Executors创建线程池

  • Executors.newCachedThreadPool();创建一个可缓存的线程池,适用于负载较轻的场景。

    • public static ExecutorService newCachedThreadPool() {
      	return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
      								60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
      }
      
      核心线程数:0   最大线程数:Integer.MAX_VALUE    非核心线程空闲存活时间:60秒
      阻塞队列为:SynchronousQueue
      
    • SynchronousQueue是一个不存储元素的阻塞队列,负责把生产者线程处理的数据直接传递给消费者线程。

    • 线程池可以无限创建线程处理任务,处理完后60后销毁线程。适用于负载较轻的场景。

  • Executors.newFixedThreadPool(10);创建固定数目线程的线程池,用于负载过重。

    • public static ExecutorService newFixedThreadPool(int nThreads) {
              return new ThreadPoolExecutor(nThreads, nThreads,
                                            0L, TimeUnit.MILLISECONDS,
                                            new LinkedBlockingQueue<Runnable>());
      }
      
      核心线程数=最大线程数,非核心线程空闲存活时间=0,阻塞队列为LinkedBlockingQueue
      
    • LinkedBlockingQueue 是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为 Integer.MAX_VALUE。显然这个线程池没有对LinkedBlockQueue传参,也就是队列的界为MAXVALUE。

    • 线程池中只能有nThreads个线程的线程池。若无限的创建线程,则会导致OOM。

  • newSingleThreadExecutor();创建只有一个线程的线程池,适用于线程之间顺序执行。

    • public static ExecutorService newSingleThreadExecutor() {
          return new FinalizableDelegatedExecutorService
             (new ThreadPoolExecutor(1, 1,
                                     0L, TimeUnit.MILLISECONDS,
                                     new LinkedBlockingQueue<Runnable>()));
      }
      
    • 核心线程数=最大线程数=1,则请求线程的任务全部放在队列里,可以顺序执行;

    • 无限创建线程会导致OOM

  • newScheduledThreadPool(10):适用于执行延时或者周期性任务。

    • public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
      	return new ScheduledThreadPoolExecutor(corePoolSize);
      }
      
    • public class ScheduledThreadPoolExecutor extends ThreadPoolExecutor
              implements ScheduledExecutorService {
              
          public ScheduledThreadPoolExecutor(int corePoolSize) {
              super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
                    new DelayedWorkQueue());
          }       
      }
      
  • 核心线程通过传参,最大线程数为MAXVALUE;

    • DelayedWorkQueue为一个内部类,也是继承AbstractQueue并实现BlockingQueue接口,属于ArrayBlockQueue和LinkedBlockQueue的兄弟类,基于DelayedQueue实现。

阿里巴巴Java开发手册明确规定,不允许使用excutors创建线程池,因为底层的LinkedBlockingQueue的最大堆积为Integer.MAX_VALUE,如果任务堆积过多会造成OOM。

2.7.3 阻塞队列串讲

  • 为什么要使用阻塞队列?

    阻塞队列满时会阻塞线程往里面加入元素,为空时会阻塞线程取元素。

    阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源。
    当队列中有任务时才唤醒对应线程从队列中取出消息进行执行。

  • 阻塞队列的实现类?

    • ArrayBlockingQueue:ArrayBlockingQueue 是一个有界的阻塞队列,其内部实现是将对象放到一个数组里。有界也就意味着,它不能够存储无限多数量的元素。它有一个同一时间能够存储元素数量的上限。你可以在对其初始化的时候设定这个上限,但之后就无法对这个上限进行修改了(译者注:因为它是基于数组实现的,也就具有数组的特性:一旦初始化,大小就无法修改)
    • DelayQueue:DelayQueue 对元素进行持有直到一个特定的延迟到期。注入其中的元素必须实现 java.util.concurrent.Delayed 接口。
    • LinkedBlockingQueue:LinkedBlockingQueue 内部以一个链式结构(链接节点)对其元素进行存储。如果需要的话,这一链式结构可以选择一个上限。如果没有定义上限,将使用 Integer.MAX_VALUE 作为上限。
    • PriorityBlockingQueue:PriorityBlockingQueue 是一个无界的并发队列。它使用了和类 java.util.PriorityQueue 一样的排序规则。你无法向这个队列中插入 null 值。所有插入到 PriorityBlockingQueue 的元素必须实现 java.lang.Comparable 接口。因此该队列中元素的排序就取决于你自己的 Comparable 实现。
    • SynchronousQueue:SynchronousQueue 是一个特殊的队列,它的内部同时只能够容纳单个元素。如果该队列已有一元素的话,试图向队列中插入一个新元素的线程将会阻塞,直到另一个线程将该元素从队列中抽走。同样,如果该队列为空,试图向队列中抽取一个元素的线程将会阻塞,直到另一个线程向队列中插入了一条新的元素。据此,把这个类称作一个队列显然是夸大其词了。它更多像是一个汇合点。
  • 阻塞队列实现原理?

    ReentranLock + Condition 实现队列的阻塞,ReentranLock 是锁,Condition是条件状态,通过等待/通知机制,来实现线程之间的通信。

2.8 多线程中死锁

模拟两个线程相互获取锁造成死锁状态

class Sisuo implements Runnable{
    private String lockA;
    private String lockB;

    Sisuo(String lockA, String lockB){
        this.lockA = lockA;
        this.lockB = lockB;
    }

    @Override
    public void run() {
        synchronized (lockA){
            System.out.println(Thread.currentThread().getName()+"拥有锁:"+lockA);
            System.out.println(Thread.currentThread().getName()+"尝试获取锁:"+lockB);
            synchronized (lockB){
                System.out.println(",,,,");
            }
        }
    }
}

public class Main {

    public static void main(String[] args) {

        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                2,
                5,
                1,
                TimeUnit.SECONDS,
                new LinkedBlockingDeque<Runnable>(3),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.AbortPolicy()
        );
        String lock1 = "LockA";
        String lock2 = "LockB";

        threadPoolExecutor.execute(new Sisuo(lock1, lock2));
        threadPoolExecutor.execute(new Sisuo(lock2, lock1));
    }
}

image-20200602135043219

出现程序执行不下去的情况。

死锁如何排查?

通过jps指令找对对应进程的PID :

jps

image-20200602135258654

查看对应进程的堆栈信息

jstack 2888

image-20200602135540453

2.9 ThreadLocal类

每个Thread内部都有一个Map(私有静态内部类ThreadLocalMap结构),我们每当定义一个ThreadLocal变量,就相当于往这个Map里放了一个key,key为一个ThreadLocal<?>类型,并定义一个对应的value。每当使用ThreadLocal,就相当于map.get(key),寻找其对应的value。

ThreadLocal的内存泄漏问题:ThreadLocalMap中key采用弱引用,可在删除key时垃圾回收,而value采用的是强引用,不会进行垃圾回收;value会随着线程销毁而消亡,内存泄漏一般出现在线程池时。避免方法就是:调用remove方法;

ThreadLocal在使用线程池时,在使用完ThreadLocal后记得清除ThreadLocalMap。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章