面经(自己整理,迭代中)——基础、JUC、集合

255 阅读1小时+

面经

相关必看源码:

ArrayList、Lock、AQS、HashMap、LinkedList、ConcurrentHashMap

1、说说你对面向对象的理解

面向对象的三大基本特征是:封装、继承、多态。

  • 封装 封装是指将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象内部信息,让外部程序通过该类提供的方法来实现对内部信息的操作和访问,这种做法有助于规范使用者的行为,让使用者只能通过事先预定的方法访问数据,提高了代码的可维护性;

  • 优点

    • 隐藏类的成员变量和实现细节,不允许外部直接访问
    • 规范使用者的行为,让使用者只能通过事先预定的方法访问数据,通过在这个方法中加入逻辑控制,限制使用者对成员变量的不合理访问
    • 可进行数据检查,从而有利于保证对象信息的完整性
    • 便于修改,提高代码的可维护性
  • 继承 继承是面向对象实现代码复用的重要手段,Java通过extends作为关键字实现类的继承,实现继承的类被称为子类,被继承的类称为父类(有的也被称为基类和超类),父类和子类的关系是一种一般和特殊的关系;

  • 优点

    • 代码共享,减少创建类的工作量,每个子类都拥有父类的方法和属性,提高了代码复用
    • 提高代码的可扩展性,很多开源框架的扩展接口都是通过继承父类来完成的
  • 缺点

    • 继承是侵入性的。只要继承,就必须拥有父类的所有属性和方法;
    • 降低代码的灵活性,子类必须拥有父类的属性和方法。
    • 增强了耦合性。当父类的常量、变量和方法被修改时,需要考虑子类的修改,而且在缺乏规范的环境下,这种修改可能会导致大段的代码需要重构。
  • 多态 多态的实现离不开继承,在设计程序时,我们可以将参数的类型定义为父类型。在调用程序时,则可以根据实际情况,传入该父类型的某个子类型的实例,这样就实现了多态。对于父类型,可以有三种形式,即普通的类、抽象类、接口。对于子类型,则要根据它自身的特征,重写父类的某些方法,或实现抽象类/接口的某些抽象方法。

继承于同一父类的多个子类在执行同一个方法时可能会表现出不同的情况,这一特性主要通过方法重写来实现

  • 实现多态需要三个条件

      1. 需要有继承关系的存在。
      1. 需要有方法的重写。
      1. 需要有父类的引用指向子类对象。
  • 优点

    • 提高了代码的维护性
    • 提高了代码的扩展性

2、请你说说Java的特点和优点,为什么要选择Java?

  • 1、Java是一门纯粹的面向对象的编程语言,吸收C++语言的各种优点,去除了C++语言中令人难以理解的多继承、指针等概念。所以Java语言在保证了强大的功能性的基础上,还比C++语言更为简单易用。
  • 2、Java平台独立性,可以做到"一次编译,到处运行"。
  • 3、java提供了很多内置的类库,通过这些类库,简化了开发人员的程序设计工作,缩短了项目的开发时间,
  • 4、最重要Java提供了垃圾回收器,将开发人员从对内存的管理中解脱出来。
  • 5、拥有良好的安全性和健壮性,java语言提供了一个防止恶意代码攻击的安全机制(数组边界检测和Bytecode校验等)。java的强类型机制、垃圾回收器、异常处理和安全检查机制使得用java语言编写的程序有很好的健壮性。
  • 6、Java还提供了对Web应用开发的支持:例如Applet、Servlet和JSP可以用来开发Web应用程序;Socket、RMI可以用来开发分布式应用程序的类库。
  • 加分回答: Java为什么可以跨平台: JVM(Java虚拟机)是Java跨平台的关键。 在运行程序之前,Java源代码(.java)需要经过编译器,将源代码翻译成字节码(.class),但字节码不能直接运行,所以必须通过JVM将字节码翻译成特定平台的机器码运行程序。但跨平台的是Java程序、而不是JVM,所以需要在不同平台下安装不同版本的JVM。

3、请你说说Java基本数据类型和引用类型

Java的数据类型分为基本数据类型引用数据类型两大类。

  • 基本数据类型共有八大类,这八大数据类型又可分为四小类,分别是整数类型(byte/short/int/long)、浮点类型(float、double)、字符类型(char)和布尔类型(boolean)。其中,int是最常用的整数类型,double是最为常用的浮点类型,除了布尔类型之外的其他7个类型,都可以看做是数字类型,它们相互之间可以进行类型转换。

    • byte:1字节(8位),数据范围是 -2^7 ~ 2^7-1
    • short:2字节(16位),数据范围是 -2^15 ~ 2^15-1
    • int:4字节(32位),数据范围是 -2^31 ~ 2^31-1
    • long:8字节(64位),数据范围是 -2^63 ~ 2^63-1
    • float:4字节(32位),数据范围大约是 -3.4*10^38 ~ 3.4*10^38
    • double:8字节(64位),数据范围大约是 -1.8*10^308 ~ 1.8*10^308
    • char:2字节(16位),数据范围是 \u0000 ~ \uffff
    • boolean:Java规范没有明确的规定,不同的JVM有不同的实现机制。
  • 引用类型包括数组、类、接口类型,还有一种特殊的null类型,所谓引用数据类型就是对一个对象的引用,对象包括实例和数组两种。

4、请你说一下抽象类和接口的区别

接口与抽象类的方法,接口与抽象类的常量与变量,单继承多实现

  • 相同点

    • 接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其它类实现和继承
    • 接口和抽象类都可以有抽象方法,实现接口或继承抽象类的普通子类都必须实现这些抽象方法
    • 1.一个类只能继承一个抽象类,但可以实现多个接口。接口可以多继承接口。
    • 2.抽象类可以全是具体的方法(没有abstract修饰的方法),接口在jdk8之前只能有抽象方法。
    • 3.接口里只能包含抽象方法和默认方法,不能为普通方法提供方法实现;抽象类则可以包含普通方法。(抽象类的成员方法的修饰符可以是public,缺省,protect,可以有静态方法,而接口的抽象方法只能是public,jdk8后可以有default的方法和静态方法)
    • 4.接口里只能定义静态常量,不能定义普通成员变量;抽象类里既可以定义普通成员变量,也可以定义静态常量(抽象类的成员变量可以是public,default,protected,private(好像也行),也可以有静态变量。接口只能有final static修饰的变量)
    • 5.抽象类可以有构造方法,接口不可以有构造方法(抽象类可以包含构造器,但抽象类的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作)
    • 接口作为系统与外界交互的窗口,体现了一种规范。对于接口的实现者来说,接口规定了实现者必须向外提供哪些服务;对于接口的调用者而言,接口规定了调用者可以调用哪些服务,以及如何调用这些服务。当在一个程序中使用接口时,接口是多个模块间的耦合标准;当在多个应用程序之间使用接口时,接口是多个程序之间的通信标准
    • 抽象类则不一样,抽象类作为系统中多个子类的共同父类,它体现的是一种模板式设计。抽象类作为多个子类的父类,它可以被当作系统实现过程中的中间产品,这个中间产品已经实现了系统的部分功能,但这个产品依然不能当作最终产 品,必须要有更进一步的完善。这种完善可能有几种不同方式。

5、请你说一下final关键字

  • 1.final被用来修饰类和类的成分。
  • 2.final属性:变量引用不可变,但对象内部内容可变;被final修饰的变量必须被初始化。
  • 3.final方法:该方法不能被重写,但子类可以使用该方法。
  • 4.final参数:参数在方法内部不允许被修改
  • 5.final类:该类不能被继承,所有方法不能被重写,但未被声明为final的成员变量可以改变。

6、说说static修饰符的用法

  • static修饰变量:属于静态变量也叫类变量,直属于类对象而不是实例,可以通过类名访问,它一般会在类加载过程中被初始化。生命周期贯穿整个程序。存储在方法区中
  • static修饰方法:即静态方法,一个类中的静态方法不能访问该类的实例变量,只能访问静态变量
  • 同时还存在一个静态初始化块,他在类加载过程中被调用用于对该类中的静态变量进行操作
  • static修饰类:即静态内部类,他只能以内部类的形式存在,可通过外部类的类名调用。它是他也只能访问到外部的的静态成员
  • Java类中包含了成员变量、方法、构造器、初始化块和内部类(包括接口、枚举)5种成员,static关键字可以修饰除了构造器外的其他4种成员。static关键字修饰的成员被称为类成员。类成员属于整个类,不属于单个对象。
  • static修饰的部分会和类同时被加载。被static修饰的成员先于对象存在,因此,当一个类加载完毕,即使没有创建 对象也可以去访问被static修饰的部分。
  • 静态方法中没有this关键词,因为静态方法是和类同时被加载的,而this是随着对象的创建存在的。静态比对象优先存在。也就是说,静态可以访问静态,但静态不能访问非静态而非静态可以访问静态。

7、请你说说String类,以及new

  • String类是由final修饰的,所以他不能被继承
  • 创建字符串有两种方式,一种是使用字符串直接量,另一种是使用new关键字,当使用字符串直接量的方式来创建字符串时,JVM会使用常量池来管理这个字符串,当使用new关键字来创建字符串时,JVM会先使用常量池来管理字符串直接量,再调用String类的构造器来创建一个新的String对象,新创建的String对象会被保存在堆内存中。对比来说,采用new的方式会多创建出一个对象来,占用了更多的内存 ,所以建议采用直接量的方式来创建字符串

8、String、StringBuffer、Stringbuilder有什么区别

  • String是final的char()修饰不可被继承不可变,即时修改也是新建一个变量,String通过不变的特性实现了线程之间的可见性,一定条件下可以保证线程安全。StringBuffer和StringBuilder对象是可变的。
  • StringBuffer实现了Synchronize封装使得它线程安全,而StringBuilder是线程不安全的。所以从运行速度来说,StringBuilder>StringBuffer>String

9、请你说说==与equals()的区别

  • 值类型是存储在内存中的堆栈,而引用类型的变量在栈中仅仅是存储引用类型变量的地址,而其本身则存储在堆中。
  • ==操作比较的是两个变量的值是否相等,对于引用型变量表示的是两个变量在堆中存储的地址是否相同,即栈中的内容是否相同。equals操作表示的两个变量是否是对同一个对象的引用,即堆中的内容是否相同。
  • ==比较的是2个对象的地址,而equals比较的是2个对象的内容。
  • 显然,当equals为true时,==不一定为true;
  • ==不能用于比较类型上没有父子关系的两个对象。 EQUALS()方法是OBJECT类提供的一个实例方法,所以所有的引用变量都能调用EQUALS()方法来判断他是否与其他引用变量相等,但使用这个方法来判断两个引用对象是否相等的判断标准与使用==运算符没有区别,它同样要求两个引用变量指向同一个对象才会返回TRUE,但如果这样的话EQUALS()方法就没有了存在的意义,所以如果我们希望自定义判断相等的标准时,可以通过重写EQUALS方法来实现。重写EQUALS()方法时,相等条件是由业务要求决定的,因此EQUALS()方法的实现是由业务要求决定的。

10、请你说说hashCode()和equals()的区别,为什么重写equals()就要重写hashcod()

  • hashCode():获取哈希码,equals():比较两个对象是否相等。
  • 如果两个对象相等,它们必须有相同的哈希码;但如果两个对象的哈希码相同,他们却不一定相等。也就是说,equals()比较两个对象相等时hashCode()一定相等,hashCode()相等的两个对象equqls()不一定相等。
  • 由于hashCode()与equals()具有联动关系,所以equals()方法重写时,通常也要将hashCode()进行重写,使得这两个方法始终满足相关的约定。

11、请你讲一下Java 8的新特性

  • 1、Lambda表达式:可将功能视为方法参数,或者将代码视为数据。使用 Lambda 表达式,可以更简洁地表示单方法接口(称为功能接口)的实例。
  • 2、方法引用:提供了非常有用的语法,可直接引用已有Java类或对象(实例)的方法或构造器。与Lambda联合使用,方法引用可以使语言的构造更紧凑简洁,减少冗余代码。 -
  • 3、对接口进行了改进:允许在接口中定义默认方法,默认方法必须使用default修饰。 -
  • 4、Stream API:新添加的Stream API(java.util.stream)支持对元素流进行函数式操作。Stream API 集成在 Collections API 中,可以对集合进行批量操作,例如顺序或并行的 map-reduce 转换。 -
  • 5、Date Time API:加强对日期与时间的处理。
  • 6、Optional 类 − Optional 类已经成为 Java 8 类库的一部分,用来解决空指针异常。
  • 7、Nashorn, JavaScript 引擎 − Java 8提供了一个新的Nashorn javascript引擎,它允许我们在JVM上运行特定的javascript应用。
  • 8、新工具 − 新的编译工具,如:Nashorn引擎 jjs、 类依赖分析器jdeps。

12、介绍一下包装类的自动拆装箱与自动装箱

  • 1、自动装箱、自动拆箱是JDK1.5提供的功能。

  • 2、自动装箱:把一个基本类型的数据直接赋值给对应的包装类型;

  • 3、自动拆箱是指把一个包装类型的对象直接赋值给对应的基本类型;

  • 4、通过自动装箱、自动拆箱功能,简化基本类型变量和包装类对象之间的转换过程

  • 不同包装类不能直接进行比较,这包括:

    • 不能用==进行直接比较,因为它们是不同的数据类型;
    • 不能转为字符串进行比较,因为转为字符串后,浮点值带小数点,整数值不带,这样它们永远都不相等;
    • 不能使用compareTo方法进行比较,虽然它们都有compareTo方法,但该方法只能对相同类型进行比较。 整数、浮点类型的包装类,都继承于Number类型,而Number类型分别定义了将数字转换为byte、short、int、long、float、double的方法。所以,可以将Integer、Double先转为转换为相同的基本数据类型(如double),然后使用==进行比较。

13、请你说说Java的异常处理机制

  • 1、异常处理机制让程序具有容错性和健壮性,程序运行出现状况时,系统会生成一个Exception对象来通知程序
  • 2、处理异常的语句由try、catch、finally三部分组成。try块用于包裹业务代码,catch块用于捕获并处理某个类型的异常,finally块则用于回收资源。
  • 3、如果业务代码发生异常,系统创建一个异常对象,并将其提交给JVM,由JVM寻找可以处理这个异常的catch块,并将异常对象交给这个catch块处理。如果JVM没有找到,运行环境终止,Java程序退出。
  • 4、Java也允许程序主动抛出异常。当业务代码中,判断某项错误的条件成立时,可以使用throw关键字向外抛出异常。

throw、throws区别

  • throws: - 只能在方法签名中使用 - 可以声明抛出多个异常,多个一场之间用逗号隔开 - 表示当前方法不知道如何处理这个异常,这个异常由该方法的调用者处理(如果mn方法也不知该怎么处理异常,这个异常就会交给JVM处理,JVM处理异常的方式是,打印异常跟踪栈信息并终止程序运行,这也就是为什么程序遇到异常会自动结束的的原因) - throws表示出现异常的一种可能性,并不一定会发生这些异常

  • throw: - 表示方法内抛出某种异常对象,throw语句可以单独使用。 - throw语句抛出的是一个异常实例,不是一个异常类,而且每次只能抛出一个异常实例 - 执行throw一定抛出了某种异常 关于finally的问题 当Java程序执行try块、catch块时遇到了return或throw语句,这两个语句都会导致该方法立即结束,但是系统执行这两个语句并不会结束该方法,而是去寻找该异常处理流程中是否包含finally块,如果没有finally块,程序立即执行return或throw语句,方法终止;如果有finally块,系统立即开始执行finally块。只有当finally块执行完成后,系统才会再次跳回来执行try块、catch块里的return或throw语句;如果finally块里也使用了return或throw等语句,finally块会终止方法,系统将不会跳回去执行try块、catch块里的任何代码。这将会导致try块、catch块中的return、throw语句失效,所以,我们应该尽量避免在finally块中使用return或throw。

  • finally代码块不执行的几种情况:

    • 如果当一个线程在执行 try 语句块或者catch语句块时被打断interrupted或者被终止killed,与其相对应的 finally 语句块可能不会执行。
      • 如果在try块或catch块中使用 System.exit(1); 来退出虚拟机,则finally块将失去执行的机会。

14、请你说说重载和重写的区别,构造方法能不能重写

  • 重载要求发生在同一个类中,多个方法之间方法名相同且参数列表不同。注意重载与方法的返回值以及访问修饰符无关。
  • 重写发生在父类子类中,若子类方法想要和父类方法构成重写关系,则它的方法名、参数列表必须与父类方法相同。另外,返回值要小于等于父类方法,抛出的异常要小于等于父类方法,访问修饰符则要大于等于父类方法。若父类方法的访问修饰符为private,则子类不能对其重写。 其实除了二者都发生在方法之间,要求方法名相同之外并没有太大相同点。
  • 同一个类中有多个构造器,多个构造器的形参列表不同就被称为构造器重载,构造器重载让Java类包含了多个初始化逻辑,从而允许使用不同的构造器来初始化对象。 构造方法不能重写。因为构造方法需要和类保持同名,而重写的要求是子类方法要和父类方法保持同名。如果允许重写构造方法的话,那么子类中将会存在与类名不同的构造方法,这与构造方法的要求是矛盾的。 父类方法和子类方法之间也有可能发生重载,因为子类会获得父类的方法,如果子类中定义了一个与父类方法名字相同但参数列表不同的方法,就会形成子类方法和父类方法的重载。

15、请介绍一下访问修饰符

  • private->default->protected->public
  • 都可以用于修饰类,方法,变量。而protected不能用于修饰类。public修饰的目标对同一个项目下所有的类都公开,protected只对同一个包下或存在父子类关系的类公开,default对同一个包下的类公开,private只能保证该类可见。
  • 对于局部变量而言,它的作用域就是他所在的方法,不可能被其它类所访问,所以不能使用访问修饰符来修饰。 对于外部类而言,它只有两种控制级别:public和默认,外部类之所以不能用protected和private修饰,是因为外部类没有处于任何类的内部,所以就没有它所在类的内部,所在类的子类两个范围,protected和private没有意义。使用public声明的外部类可以被所有类引用;不使用访问修饰符创建的外部类只有同一个包内的类能引用。

16、请你说说泛型、泛型擦除

  • Java在1.5版本中引入了泛型,在没有泛型之前,每次从集合中读取对象都必须进行类型转换,而这么做带来的结果就是:如果有人不小心插入了类型错误的对象,那么在运行时转换处理阶段就会出错。在提出泛型之后,我们可以告诉编译器集合中接受哪些对象类型。编译器会自动的为你的插入进行转化,并在编译时告知是否插入了类型错误的对象。这使程序变得更加安全更加清楚
  • Java语言的泛型实现方式是擦拭法(Type Erasure)。 所谓擦拭法是指,虚拟机对泛型其实一无所知,所有的工作都是编译器做的。Java的泛型是由编译器在编译时实行的,编译器内部永远把所有类型T视为Object处理,但是,在需要转型的时候,编译器会根据T的类型自动为我们实行安全地强制转型。

Java 的泛型是伪泛型,因为在编译期间所有的泛型信息都会被擦除掉,泛型参数保留为原始类型。譬如 List 在运行时仅用一个 List 来表示(所以我们可以通过反射 add 方法来向 Integer 的泛型列表添加字符串,因为编译后都成了 Object),这样做的目的是为了和 Java 1.5 之前版本进行兼容。

17、请说说你对反射的了解

通过反射机制,我们可以实现如下的操作:

  • 反射的使用是基于class对象来处理的。在java中每一个类都存在着它的class对象,通过编译后,每一个类都会生成与之相关.class文件用来存储这些类信息。

  • 程序运行时,可以通过反射获得任意一个类的Class对象,并通过这个对象查看这个类的信息;

  • 程序运行时,可以通过反射创建任意一个类的实例,并访问该实例的成员;

  • 程序运行时,可以通过反射机制生成一个类的动态代理类或动态代理对象。

  • 能够在程序运行期间,对于任意一个类,都能知道它所有的方法和属性,对于任意一个对象,都能知道他的属性和方法。 获取Class对象的三种方式:getClass();xx.class;Class.forName("xxx");

  • 反射的优缺点: 优点:运行期间能够动态的获取类,提高代码的灵活性。 缺点:性能比直接的Java代码要慢很多。

  • 反射的使用大大的提高了代码的可扩展性,实现了一些本不能实现的功能,例如动态代理中就常常用到了反射。但是它破环了java中内部细节不对外部公开的封装特性,所以滥用可能会导致一系列安全问题。同时使用反射会降低性能,因为java不会对反射代码进行优化。

  • Java的反射机制在实际项目中应用广泛,常见的应用场景有:

    • 使用JDBC时,如果要创建数据库的连接,则需要先通过反射机制加载数据库的驱动程序;
    • 多数框架都支持注解/XML配置,从配置中解析出来的类是字符串,需要利用反射机制实例化;
    • 面向切面编程(AOP)的实现方案,是在程序运行时创建目标对象的代理类,这必须由反射机制来实现。

JUC

18、请你说说多线程

  • 线程是操作系统调度的最小单元,它可以让一个进程并发地处理多个任务,也叫轻量级进程。所以,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、堆栈、局部变量,并且能够共享进程内的资源。由于共享资源,处理器便可以在这些线程之间快速切换,从而让使用者感觉这些线程在同时执行。 总的来说,操作系统可以同时执行多个任务,每个任务就是一个进程。进程可以同时执行多个任务,每个任务就是一个线程。一个程序运行之后至少有一个进程,而一个进程可以包含多个线程,但至少要包含一个线程。

  • 好处:减少程序响应时间;提高cpu利用率;创建和切换开销小,数据共享效率高;简化程序结构

    • 更多的CPU核心 现代计算机处理器性能的提升方式,已经从追求更高的主频向追求更多的核心发展,所以处理器的核心数量会越来越多,充分地利用处理器的核心则会显著地提高程序的性能。而程序使用多线程技术,就可以将计算逻辑分配到多个处理器核心上,显著减少程序的处理时间,从而随着更多处理器核心的加入而变得更有效率。
    • 更快的响应时间 我们经常要针对复杂的业务编写出复杂的代码,如果使用多线程技术,就可以将数据一致性不强的操作派发给其他线程处理(也可以是消息队列),如上传图片、发送邮件、生成订单等。这样响应用户请求的线程就能够尽快地完成处理,大大地缩短了响应时间,从而提升了用户体验。
    • 更好的编程模型 Java为多线程编程提供了良好且一致的编程模型,使开发人员能够更加专注于问题的解决,开发者只需为此问题建立合适的业务模型,而无需绞尽脑汁地考虑如何实现多线程。一旦开发人员建立好了业务模型,稍作修改就可以将其方便地映射到Java提供的多线程编程模型上。
  • 缺点

    • 可能产生死锁;频繁的上下文切换可能会造成资源的浪费;在并发编程中如果因为资源的限制,多线程串行执行,可能速度会比单线程更慢。 ,但不利于资源的管理和保护
    • 线程和进程最大的不同在于基本上各个进程是独立的,而各个线程则不一定,因为同一进程中的线程极有可能会互相影响。
  • 使用多线程可能带来什么问题

    • 并发编程的目的就是为了能提高程序的运行效率和运行速度,但是并不是一直能够提高运行速度和效率,而且并发编程可能会遇到很多问题,比如内存泄漏、死锁、线程不安全等。

多线程之间可以在异步执行的情况下使用synchronize或者retrentLock进行同步操作。在多线程异步对资源进行调度的过程中需要满足线程安全的三大特性:原子性,可见性,有序性。同时在java内存模型中的虚拟机栈以线程为单位将其分割成多个栈帧。每个栈帧包括局部变量表,操作数栈,动态链接和方法出口。 对于最简单的输出hello world程序,就包括四个线程, main,清除reference的线程,和finalize方法有关的线程,处理jvm信号的线程

19、说说线程的创建方式

  • 1、继承Thread类,重写run()方法;2.实现Runnable接口,并实现该接口的run()方法;3.实现Callable接口,重写call()方法。前两种方式线程执行完后都没有返回值,最后一种带返回值;一般推荐实现Runnable接口的方式。
  • 创建Callable实现类的实例,并以该实例作为参数,创建FutureTask对象。 - 使用FutureTask对象作为参数,创建Thread对象,然后启动线程。 - 调用FutureTask对象的get()方法,获得子线程执行结束后的返回值。

20、说说线程的生命周期和状态

状态名称说明
NEW初始状态,线程被构建但是还没有调用start方法
RUNNABLE运行状态,Java线程将操作系统中的运行和就绪都称为‘运行中’
BLOCKED阻塞状态,表示线程阻塞于锁
WAITING等待状态,表示线程进入等待状态,进入该状态表示当前线程,需要等待其他线程做特定动作唤醒
TIME_WAITING超时等待状态,该状态不同于WAITING,他是可以在指定的世界自行返回的
THRMINATED终止状态,表示当前线程已经执行完毕
RUNING线程获得CPU资源,正在执行alive(其实不是真实存在这个状态)

线程创建之后他将处于new新建状态,调用**start()** 方法后开始运行,线程这时候处于**READY(可运行状态)** 。可运行状态的线程获得了CPU时间片后就处于**RUNNING(运行状态)**

当线程执行wait()方法之后,线程进入**WAITING(等待)** 状态。进入等待状态的线程需要依据其他线程的通知才能够返回运行状态,此外,线程在执行同步方法时,在没有获取到锁的情况下,会进入到阻塞状态。而**TIMED_WAITING超时等待)状态相当于在等待状态的基础上增加了超时限制,比如sleep(long millis)方法可以将Java线程置于TIME_WAITING状态,当超时时间到点后Java线程将会回到runnable状态线程在执行Runnablerun() 方法之后将会进入到TERMINATED(终止)** 状态。

img

阻塞状态是线程由于某些原因放弃CPU使用,暂时停止运行。

(1)等待阻塞:线程调用start()方法,JVM会把这个线程放入等待池中,该线程需要其他线程调用notify()或notifyAll()方法才能被唤醒。

(2)同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其他线程占用,则JVM会把该线程放入锁池中。

(3)其他阻塞:运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

21、为什么没有区分READY和RUNNING状态呢

现在的时分time-sharing多任务multi-task)操作系统架构通常都是用抢占式轮转调度。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到ready状态)。线程切换的如此之快,区分这两种状态就没什么意义了。

22、什么是上下文切换

上下文指的其实就是线程自己的运行条件和状态,比如程序计数器栈信息等,当出现如下情况的时候,线程会从占用cpu状态中退出

  • 主动让出cpu,比如调用sleep()wait()
  • 时间片用完,因为操作系统要防止一个线程或者进程长时间占用cpu导致其他线程或者进程饿死
  • 调用了阻塞类型的系统中断,比如IO,线程被阻塞
  • 被终止或结束运行

其中前三种都会发生上下文切换,线程切换以为着要保存当前线程的上下文,留着在下一次占用cpu的时候恢复线程。并加载下一个将要占用cpu程序的上下文,这就是上下文切换

每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下

23、说说sleep()方法和wait方法区别和共同点

  • 所属的类型不同 - wt()是Object类的实例方法,调用该方法的线程将进入WTING状态。 - sleep()Thread类的静态方法,调用该方法的线程将进入TIMED_WTING状态。
  • wt()依赖于synchronized锁,它必须通过监视器进行调用,在调用后线程会释放锁。 sleep()不依赖于任何锁,所以在调用后它也不会释放锁
  • 两者都可以暂停线程的执行
  • wait()主要用于线程间通信,sleep()通常用于暂停执行 返回的条件不同
  • 调用wt()进入等待状态的线程,需要由notify()/notifyAll()唤醒,从而返回。
  • 调用sleep()进入超时等待的线程,需要在超时时间到达后自动返回。
  • wt()方法也支持超时参数,线程调用带有超时参数的wt()会进入TIMED_WTING状态,在此状态下的线程可以通过notify()/notifyAll()唤醒从而返回,若在达到超时时间后仍然未被唤醒则自动返回。 如果采用Lock进行线程同步,则不存在同步监视器,此时需要使用Condition的方法实现等待。Condition对象是通过Lock对象创建出来的,它的await()方法会导致线程进入WAITING状态,它的带超时参数的await()方法会导致线程进入TIMED_WTING状态,当调用它的signal()/signalAll()方法时,线程会被唤醒从而返回

sleep(),wait(),join(),yield()的区别

锁池:

所有需要竞争同步锁(sycnchronized)的线程都会放在锁池中,某个对象的锁已经被其中一个线程得到,其他线程需要在这个锁池中进行等待,当前面的线程释放同步锁后,锁池中的线程去竞争同步锁,当某个线程获得同步锁已经其他所需资源(除cpu资源外)后会进入就绪队列,等待分配cpu资源运行.

等待池:

当我们调用了wait()方法后,线程会放在等待池中,等待池的线程是不会去竞争同步锁的,只有调用了notify()或者notifyAll()方法后等待池的线程才会开始竞争锁,notify()是随机从等待池中选出一个线程放到锁池,而notifyAll()是将等待池中的全部线程放到锁池当中.

sleep()wait()的区别:

sleepThread类的静态本地方法,waitobject类的本地方法;

sleep方法是不会释放lock,但是wait会释放,而且wait会加入到等待队列中;

sleep就是把cpu的执行资格和执行权释放出去,一定时间内不再运行池线程,超时之后再取回cpu资源,参与cpu调度,获取到cpu资源后就可以继续运行,如果执行sleep()时,该线程有锁,sleep会带着这个锁进入冻结状态,即sleep该线程,与该线程竞争锁的其他线程也获取不到锁,从而不可能进入就绪状态.如果在sleep期间其他线程调用了interrupt()方法,那么这个线程会抛出interruptexception异常,这点和wait是一样的.

sleep方法不依赖于同步锁,可以独立使用,而wait()需要依赖同步锁synchronized关键字;

sleep不需要被唤醒(超时后自动退出阻塞),但是wait需要被中断(需要其他线程notify唤醒);

sleep一般用于当前线程休眠,或者轮循暂停操作,wait则多用于多线程之间的通信;

sleep会让出cpu执行时间且强制上下文切换,而wait则不一定,wait后可能还有机会重新竞争到锁继续执行.

yield():

yield()执行后线程直接进入就绪状态,马上释放cpu的执行权,但是依然保留了cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到cpu资源继续执行.

join():

join()执行后线程进入阻塞状态,例如在线程B中调用了A的join(),那么线程B会进入阻塞状态,直到线程A结束或者中断线程.

24、为什么我们调用start()方法时会执行run()方法,为什么我们不能直接调用run()方法

因为在new一个Thread,线程进入了新建状态,调用start()方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始工作了,start()会执行线程响应的准备工作,然后自动执行run()方法,但是直接执行run()方法的话,会把run()方法当成一个主线程下的普通方法执行,并不会在某个线程中执行他

25、请你说说死锁定义及发生的条件

  • 死锁 两个或两个以上的进程在执行过程中,因争夺共享资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。这些永远在互相等待的进程称为死锁进程。

  • 产生死锁的4个必要条件

    • 互斥条件:该资源任意一个时刻只有一个线程占用。
    • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
    • 不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才能释放资源
    • 循环等待条件(环路等待条件):若干进程之间形成一种头尾相接的循环等待资源关系

当线程进入对象的synchronized代码块时,便占有了资源,直到它退出该代码块或者调用wait方法,才释放资源,在此期间,其他线程将不能进入该代码块。

  • 如何预防死锁

    • 破坏请求与保持条件:一次性申请所有的资源
    • 破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动是否它占有的资源
    • 破坏循环等待条件:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件

如何解决死锁问题

  1. jps命令定位进程号
  2. jstack找到死锁查看

26、说说怎么保证线程安全

Java保证线程安全的方式有很多,其中较为常用的有三种,按照资源占用情况由轻到重排列,这三种保证线程安全的方式分别是原子类、volatile、锁。

  • JDK从1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。在atomic包里一共提供了17个类,按功能可以归纳为4种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。无论原子更新哪种类型,都要遵循 “比较和替换”规则,即比较要更新的值是否等于期望值,如果是则更新,如果不是则失败
  • volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性” ,从而可以保证单个变量读写时的线程安全可见性问题是由处理器核心的缓存导致的,每个核心均有各自的缓存,而这些缓存均要与内存进行同步volatile具有如下的内存语义:当写一个volatile变量时,该线程本地内存中的共享变量的值会被立刻刷新到主内存;当读一个volatile变量时,该线程本地内存会被置为无效,迫使线程直接从主内存中读取共享变量
  • 原子类和volatile只能保证单个共享变量的线程安全,锁则可以保证临界区内的多个共享变量的线程安全,Java中加锁的方式有两种,分别是synchronized关键字和Lock接口。synchronized是比较早期的API,在设计之初没有考虑到超时机制、非阻塞形式,以及多个条件变量。若想通过升级的方式让它支持这些相对复杂的功能,则需要大改它的语法结构,不利于兼容旧代码。因此,JDK的开发团队在1.5新增了Lock接口,并通过Lock支持了上述的功能,即:支持响应中断、支持超时机制、支持以非阻塞的方式获取锁、支持多个条件变量(阻塞队列)

除了上述三种方式之外,还有如下几种方式:

  • 无状态设计 线程安全问题是由多线程并发修改共享变量引起的,如果在并发环境中没有设计共享变量,则自然就不会出现线程安全问题了。这种代码实现可以称作“无状态实现”,所谓状态就是指共享变量。

  • 不可变设计 如果在并发环境中不得不设计共享变量,则应该优先考虑共享变量是否为只读的,如果是只读场景就可以将共享变量设计为不可变的,这样自然也不会出现线程安全问题了。具体来说,就是在变量前加final修饰符,使其不可被修改,如果变量是引用类型,则将其设计为不可变类型(参考String类)

  • 并发工具 java.util.concurrent包提供了几个有用的并发工具类,一样可以保证线程安全:

    • Semaphore:就是信号量,可以控制同时访问特定资源的线程数量。
    • CountDownLatch:允许一个或多个线程等待其他线程完成操作。
    • CyclicBarrier:让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。
  • 本地存储 我们也可以考虑使用ThreadLocal存储变量,ThreadLocal可以很方便地为每一个线程单独存一份数据,也就是将需要并发访问的资源复制成多份。这样一来,就可以避免多线程访问共享变量了,它们访问的是自己独占的资源,它从根本上隔离了多个线程之间的数据共享

27、JUC包中的原子类是哪几类

  • 基本类型

    • AtomicInteger :整形原子类
    • AtomicLong:长整型原子类
    • AtomicBoolean :布尔型原子类
  • 数组类型

    • AtomicIntegerArray :整形数组原子类
    • AtomicLongArray :长整形数组原子类
    • AtomicReferenceArray :引用类型数组原子类
  • 引用类型

28、说说你了解的线程同步方式

Java主要通过加锁的方式实现线程同步

synchronized可以加在三个不同的位置,对应三种不同的使用方式,这三种方式的区别是锁对象不同

  • 加在普通方法上,则锁是当前的实例(this)
  • 加在静态方法上,则锁是当前类的Class对象。
  • 加在代码块上,则需要在关键字后面的小括号里,显式指定一个对象作为锁对象。 不同的锁对象,意味着不同的锁粒度,所以我们不应该无脑地将它加在方法前了事,尽管通常这可以解决问题。而是应该根据要锁定的范围,准确的选择锁对象,从而准确地确定锁的粒度,降低锁带来的性能开销Lock同上回答。
  • synchronized采用“CAS+Mark Word”实现,为了性能的考虑,并通过锁升级机制降低锁的开销。在并发环境中,synchronized会随着多线程竞争的加剧,按照如下步骤逐步升级:无锁、偏向锁、轻量级锁、重量级锁Lock则采用“CAS+volatile”实现,其实现的核心是AQSAQS是线程同步器,是一个线程同步的基础框架,它基于模板方法模式。在具体的Lock实例中,锁的实现是通过继承AQS来实现的,并且可以根据锁的使用场景,派生出公平锁、不公平锁、读锁、写锁等具体的实现。

29、synchronized关键字的了解与使用

synchronized是为了防止多个线程对同一资源同时进行操作,synchronized可以保证被他修饰的代码块或者方法同一时间只能被一个线程访问

  • 修饰实例方法:作用于当前对象实例加锁,进入代码块之前要获得当前实例对象锁
synchronized public void test(){}
  • 修饰静态方法:也就是给当前的类加锁,会作用于当前类的所有实例对象,因为静态成员不属于任何一个实例对象而是属于类的成员,所以当我们的一个线程A调用非静态synchronized修饰的方法是,我们的b需要调用这个实例对象所属的类的静态synchronized方法是被允许的,不会发生互斥现象,因为访问非静态方法时所需的锁是当前实例对象的锁,而访问静态synchronized需要的是当前类的锁
  • 修饰代码块:指定加锁对象,给对象/类加锁
  • 类对象锁
 new Thread(() -> {
            synchronized (SynchronizedTest.class){
            }
        }).start();
  • 实例对象锁
  void a () throws Exception {
        new Thread(() -> {
            synchronized (this){
            }
        }).start();
    }

31、讲一下synchronized关键字的底层原理

位置、对象头、锁升级。

synchronized同步语句块的实现使用的是monitorentermonitorexit指令,其中monitorenter指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置。 在执行monitorenter时会尝试获得对象锁,如果当前锁的计数器为0则表示可以被获取,获取后将锁的技术器设为1也就是+1

image-20220524221902782.png

  • 对象锁的拥有者线程才可以执行monitorexit指令来释放锁,在执行monitroexit指令后,将锁计数器设为0,表明锁被释放,其他线程可以尝试获取锁
  • synchronized修饰方法的时候并没有monitorenter指令和monitorexit指令,取而代之的确实ACC_SYNCHRONIZED标识,该表示指明了方法是一个同步方法。JVM通过该标识来辨别是否声明为同步方法,从而执行响应的同步调用

synchronized锁主要是根据对象头中的markwordmoniter锁来实现的,java锁是基于对象锁,每个对象都关联了一个moniter锁,对象在jvm中分为对象头,实例数据,对齐方式三部分。

对象头包含markword和类型指针,Java对象头包含三部分,分别是Mark WordClass Metadata AddressArray length。其中,Mark Word用来存储对象的hashCode及锁信息,Class Metadata Address用来存储对象类型的指针,而Array length则用来存储数组对象的长度。如果对象不是数组类型,则没有Array length信息。mark word存储了对象的分代年龄,hashcode值,锁状态,锁记录,是否偏向,偏向线程id等信息,通过锁记录来关联monitor对象

monitor对象主要有ownerwaitsetentryListcount四个属性。owner用来标识当前monitor对象属于哪个线程,waitset存储了等待状态的线程,entrylist存储了阻塞状态的线程。

当多线程访问同步代码时,会进入到entryList中,线程通过CAS的方式将owner设为当前线程,计数器+1,修改失败的线程则进入阻塞状态。

当线程执行了wait方法后,就会进入到waitset中等待,将owner设为null,并计数器-1

当线程执行notifynotifyAll方法时,会将WaitSet的线程加入到entryList中。

当线程执行完代码后就会释放锁,计数器为0则代表锁被释放。

Java 6为了减少获取锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。所以,在Java 6中,锁一共被分为4种状态,级别由低到高依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态。随着线程竞争情况的升级,锁的状态会从无锁状态逐步升级到重量级锁状态。锁可以升级却不能降级,这种只能升不能降的策略,是为了提高效率。 synchronized的早期设计并不包含锁升级机制,所以性能较差,那个时候只有无锁和有锁之分。是为了提升性能才引入了偏向锁和轻量级锁,所以需要重点关注这两种状态的原理,以及它们的区别。

偏向锁,顾名思义就是锁偏向于某一个线程。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程再进入和退出同步块时就不需要做加锁和解锁操作了,只需要简单地测试一下Mark Word里是否存储着自己的线程ID即可。

轻量级锁,就是加锁时JVM先在当前线程栈帧中创建用于存储锁记录的空间,并将Mark Word复制到锁记录中,官方称之为Displaced Mark Word。(LockRecord用于轻量级锁优化,当解释器执行monitorenter字节码轻度锁住一个对象时,就会在获取锁的线程的栈上显式或者隐式分配一个LockRecord.这个LockRecord存储锁对象markword的拷贝(Displaced Mark Word),在拷贝完成后,首先会挂起持有偏向锁的线程,因为要进行尝试修改锁记录指针,MarkWord会有变化,所有线程会利用CAS尝试将MarkWord的锁记录指针改为指向自己(线程)的锁记录,然后lockrecord的owner指向对象的markword,修改成功的线程将获得轻量级锁。失败则线程升级为重量级锁。释放时会检查markword中的lockrecord指针是否指向自己(获得锁的线程lockrecord),使用原子的CAS将Displaced Mark Word替换回对象头,如果成功,则表示没有竞争发生,如果替换失败则升级为重量级锁。整个过程中,LockRecord是一个线程内独享的存储,每一个线程都有一个可用Monitor Record列表。)(解锁)然后线程尝试以CAS方式将Mark Word替换为指向锁记录的指针,如果成功则当前线程获得锁,如果失败则表示其他线程竞争锁,此时当前线程就会通过自旋来尝试获取锁。

锁升级的过程 (目的是为了提高获得锁和释放锁的效率):

  1. 开始,没有任何线程访问同步块,此时同步块处于无锁状态。
  2. 然后,线程1首先访问同步块,它以CAS的方式修改Mark Word,尝试加偏向锁。由于此时没有竞争,所以偏向锁加锁成功,此时Mark Word里存储的是线程1ID
  3. 然后,线程2开始访问同步块,它以CAS的方式修改Mark Word,尝试加偏向锁。由于此时存在竞争,所以偏向锁加锁失败,于是线程2发起撤销偏向锁的流程(清空线程1ID),于是同步块从偏向线程1的状态恢复到了可以公平竞争的状态。
  4. 然后,线程1和线程2共同竞争,它们同时以CAS方式修改Mark Word,尝试加轻量级锁。由于存在竞争,只有一个线程会成功,假设线程1成功了。但线程2不会轻易放弃,它认为线程1很快就能执行完毕,执行权很快会落到自己头上,于是线程2继续自旋加锁
  5. 最后,如果线程1很快执行完,则线程2就会加轻量级锁成功,锁不会晋升到重量级状态。也可能是线程1执行时间较长,那么线程2自旋一定次数后就放弃自旋,并发起锁膨胀的流程。届时,锁被线程2修改为重量级锁,之后线程2进入阻塞状态。而线程1重复加锁或者解锁时,CAS操作都会失败,此时它就会释放锁并唤醒等待的线程。 总之,在锁升级的机制下,锁不会一步到位变为重量级锁,而是根据竞争的情况逐步升级的。当竞争小的时候,只需以较小的代价加锁,直到竞争加剧,才使用重量级锁,从而减小了加锁带来的开销

扩展 image-20220526113443446.png

  • normal,正常对象,使用markwork的最后3bits来标记,001就表示正常对象
  • Biased,偏向锁标记,使用markwork的最后3bits来标记,跟正常对象虽然有区别,但区别不大,101就表示偏向锁
  • 轻量级锁,最后2bits来标记,00就表示轻量级锁
  • 重量级锁,最后2bits来标记,10就表示重量级锁
  • 最后2bits为11就表示需要GC的对象

使用monitor对象来实现重量级锁,如果使用重量级锁,加锁过程就需要先去关联monitor对象,然后还需要各种判断。 重量级.png asychronized关键字实现重量级锁的原理 字节码.png

monitorentermonitorexit就是操作monitor对象,会有性能损耗,所以引入轻量级锁。

自旋优化

当出现重量级锁竞争的时候,不会马上进入阻塞,阻塞会进入上下文切换,会影响性能的,而是先使用自旋重试,自旋只有多核CPU才有意义,一个核进行资源访问,另一个核进行尝试加锁,如果是一个核,这个核在访问资源,那也没必要花时间去重试,所以自旋必然是多核CPU才有意义。JVM会自动控制自旋重试次数,只有多核才有意义。

轻量级锁

此时锁为轻量级的时候就不是再去判断当前锁的前23位线程id了,而是通过前25位有一个指向栈中锁记录的指针去判断,当一个线程想要获得某个对象锁的时候,假如看到锁标志位00那么就知道他是轻量级锁,此时会在虚拟机栈中开辟一块称为Lock Record的空间,存放的是对象头中MarkWord的副本以及owner指针,线程通过cas去尝试获得锁,一旦获得那么将会复制该对象头中的MarkWordLock Record中,并且将Lock Recod中的owner指针指向该对象,并且此时对象的前30位会生成一个指针指向线程虚拟机栈中的Lock Record,这样就实现了对象或者线程的绑定, 利用线程栈中的锁标记来加锁。加锁过程只是替换对象头信息即可,这比重量级锁使用monitor来说性能会有提升,这就是对重量级锁的优化。 1.png 轻量级锁的加锁过程也就是交换线程栈和对象头信息即可,这样就会优化monitor了。
2.png

3.png

加锁

不管加锁成功与否,都会执行一次CAS操作。

  • 成功:对象头最后为01,只需要交换对象头信息,则一定成功

  • 失败:如果已经加锁,对象头最后为00(不可能为10,如果是10则是重量级锁,此时已经不可能使用轻量级锁去加锁),表示已经加锁,加锁失败会有两种情况

    • 升级锁:如果请求加锁的线程是两个线程,升级为重量级锁,引入monitor对象
    • 锁重入:如果请求加锁的线程是同一个线程,则只是锁重入,再在线程栈中添加一条锁记录

4.png

锁重入:一个线程对同一个对象多次加锁

  • 如果获取的锁记录是取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
  • 如果取值不为 null ,使用CAS操作,交换对象头信息完成解锁
  • 如果解锁失败,则进入重量级锁的解锁过程

虽然多线程会对一个资源进行加锁,但是如果这些线程访问是错开的,也就是这些线程不会竞争资源,这时候使用轻量级锁能提高性能,毕竟没有引入monitor对象,只是进行CAS操作,这是轻量级锁引入的原因。线程之间不会竞争,如果出现竞争,依然会使用重量级锁,所以轻量级锁就是用来优化重量级锁性能问题的。

偏向锁 偏1.png 轻量级锁在没有竞争时,每次重入仍然需要执行CAS操作,CAS也会影响性能,所以引入偏向锁。 偏2.png 当前markword的后两位是01的时候判断倒数第三个bit位是否是1,如果是1的话就说明就是一个偏向锁,如果当前为偏向锁,那么再去读他的前23个bit,这23个bit的值就是线程id,通过线程id来确认当前想要获得对象锁的这个线程,如果正确的话直接调用这个对象的资源,如果idfalse则表示有多个线程正在竞争锁,则发生锁升级,升级为轻量级锁

注意到偏向锁的对象头,里面有线程id,所以偏向锁会减少CAS操作,在一定程度上优化轻量级锁.

JVM默认是开启偏向锁的,但不会在程序启动时就生效,而是有一点延迟,可以加VM参数-XX:BiasedLockingStartupDelay=0来禁用延迟,使用asychronized加锁,会优先使用偏向锁。 VM参数 -XX:-UseBiasedLocking 禁用偏向锁,-XX:+UseBiasedLocking 使用偏向锁。

偏向撤销

  • 对象调用hashCode()方法会禁用该对象的偏向锁,原因就是调用了hashCode()方法,对象头就没有地方存放线程id了,只能禁用该对象的偏向锁。重量级锁在monitor对象中存储hashCode
  • 当两个及以上线程使用同一个对象时,偏向锁将会升级为轻量级锁,如果这些线程会产生资源竞争,则进一步升级为重量级锁。
  • 对象调用wait/notify,也会撤销对象的偏向状态,原因是只有重量级锁才会有wait/notify机制
  • 连续撤销偏向超过40次(超过阈值),jvm会认为确实偏向错了,于是所有类都不可偏向,新建的对象也不可以偏向

批量重偏向

当撤销偏向超过20次后(超过阈值),JVM 会觉得是不是偏向错了,这时会在给对象加锁时,又会重新开始偏向。

锁消除

-XX:-EliminateLocks,关闭锁消除
-XX:+EliminateLocks,开启锁消除,默认开启

总结

对于 synchronized 锁来说,锁的升级主要是通过 Mark Word 中的锁标记位与是否是偏向锁标记为来达成的;synchronized 关键字所对象的锁都是先从偏向锁开始,随着锁竞争的不断升级,逐步演化至轻量级锁,最后变成了重量级锁。

  • 偏向锁:针对一个线程来说的,主要作用是优化同一个线程多次获取一个锁的情况, 当一个线程执行了一个 synchronized 方法的时候,肯定能得到对象的 monitor ,这个方法所在的对象就会在 Mark Work 处设为偏向锁标记,还会有一个字段指向拥有锁的这个线程的线程 ID 。当这个线程再次访问同一个 synchronized 方法的时候,如果按照通常的方法,这个线程还是要尝试获取这个对象的 monitor ,再执行这个 synchronized 方法。但是由于 Mark Word 的存在,当第二个线程再次来访问的时候,就会检查这个对象的 Mark Word 的偏向锁标记,再判断一下这个字段记录的线程 ID 是不是跟第二个线程的 ID 是否相同的。如果相同,就无需再获取 monitor 了,直接进入方法体中。 如果是另一个线程访问这个 synchronized 方法,那么实际情况会如何呢?:偏向锁会被取消掉

  • 轻量级锁:若第一个线程已经获取到了当前对象的锁,这是第二个线程又开始尝试争抢该对象的锁,由于该对象的锁已经被第一个线程获取到,因此它是偏向锁,而第二个线程再争抢时,会发现该对象头中的 Mark Word 已经是偏向锁,但里面储存的线程 ID 并不是自己(是第一个线程),那么她会进行 CAS(Compare and Swap),从而获取到锁,这里面存在两种情况:

    • 获取到锁成功(一共只有两个线程):那么它会Mark Word 中的线程 ID 由第一个线程变成自己(偏向锁标记位保持不表),这样该对象依然会保持偏向锁的状态
    • 获取锁失败(一共不止两个线程):则表示这时可能会有多个线程同时再尝试争抢该对象的锁,那么这是偏向锁就会进行升级,升级为轻量级锁
  • 旋锁,若自旋失败,那么锁就会转化为重量级锁,在这种情况下,无法获取到锁的线程都会进入到 moniter(即内核态),自旋最大的特点是避免了线程从用户态进入到内核态。

32、synchronized和Lock有什么区别

使用方式、主要特性、实现机制

  • 使用方式的区别 synchronized关键字可以作用在静态方法、实例方法和代码块上,它是一种隐式锁,即我们无需显式地获取和释放锁,所以使用起来十分的方便。在这种同步方式下,我们需要依赖Monitor(同步监视器)来实现线程通信。若关键字作用在静态方法上,则Monitor就是当前类的Class对象;若关键字作用在实例方法上,则Monitor就是当前实例(this);若关键字作用在代码块上,则需要在关键字后面的小括号里显式指定一个对象作为MonitorLock接口是显式锁,即我们需要调用其内部定义的方法显式地加锁和解锁,相对于synchronized来说这显得有些繁琐,但是却提供了更大的灵活性。在这种同步方式下,我们需要依赖Condition对象来实现线程通信,该对象是由Lock对象创建出来的,依赖于Lock。每个**Condition代表一个等待队列**,而一个Lock可以创建多个Condition对象。相对而言,每个**Monitor也代表一个等待队列**,但synchronized只能有一个Monitor。所以,在实现线程通信方面,Lock接口具备更大的灵活性。

  • 功能特性的区别 synchronized是早期的API,Lock则是在JDK 1.5时引入的。在设计上,Lock弥补了synchronized的不足,它新增了一些特性,均是synchronized不具备的,这些特性包括:

    • 可中断地获取锁:线程在获取锁的过程中可以被中断。
    • 非阻塞地获取锁:该方法在调用后立刻返回,若能取到锁则返回true,否则返回false
    • 可超时地获取锁:若线程在到达超时时间后仍未获得锁,并且线程也没有被中断,则返回 false
  • 实现机制的区别 synchronized的底层是采用Java对象头来存储锁信息的,对象头包含三部分,分别是Mark WordClass Metadata AddressArray length。其中,Mark Word用来存储对象的hashCode及锁信息,Class Metadata Address用来存储对象类型的指针,而Array length则用来存储数组对象的长度。 AQS队列同步器,是用来构建锁的基础框架,Lock实现类都是基于AQS实现的。AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息

  • 早期的synchronized性能较差,不如Lock。后来synchronized在实现上引入了锁升级机制,性能上已经不输给Lock了。锁升级过程也可谈

精简版

  • 两者都是可重用锁(可重用锁指的是自己可以再次获取自己的内部锁,比如一个线程一个获得了某个对象锁,但是还没有释放,但是现在他又想获取对象锁还是可以获取的,但是如果是不可重入锁,就会造成死锁

  • synchronized依赖于JVMReentrantLock依赖于APIJDK层面的实现需要lock()unlock方法赔偿try/finally语句块来完成)

  • ReentrantLocksynchronized多了一些高级功能比如

    • 可实现公平锁:ReentrantLock可以指定是公平锁还是非公平锁,而synchronized只能是非公平,所谓公平锁就是先进入等待的线程先获得锁。reentrantLock默认情况是非公平的,可以通过构造方法来制定是否公平
    • 可实现选择性通知,也就是可以有多个等待队列

CAS(compare and swap):(不断的进行CAS操作,通常会配置自旋次数来防止死循环)并且CAS必须是原子操作,也就是说他这个比较和替换必须是原子性的,为什么呢,假设当我们a线程进行比较了之后还没有进行swap操作但是此时时间片轮换到b线程了,此时b一比较也是true,就会造成三个人一起约会,这种行为是不允许的。所以就有了原子类。

33、说说Java中常用的锁及原理

对象头、AQS

  • Java中加锁有两种方式,分别是synchronized关键字和Lock接口,而Lock接口的经典实现是ReentrantLock。另外还有ReadWriteLock接口,它的内部设计了两把锁分别用于读写,这两把锁都是Lock类型,它的经典实现是ReentrantReadWriteLock。其中,synchronized的实现依赖于对象头,Lock接口的实现则依赖于AQS
  • synchronized和Lock底层答答

加分回答

  • ReentrantLock通过内部类Sync定义了锁,它还定义了Sync的两个子类FrSyncNonfrSync,这两个子类分别代表公平锁和非公平锁。Sync继承自AQS,它不仅仅使用AQS的同步状态记录锁的信息,还利用同步状态记录了重入次数同步状态是一个整数,当它为0时代表无锁,当它为N时则代表线程持有锁并重入了NReentrantReadWriteLock支持重入的方式与ReentrantLock一致,它也定义了内部类Sync,并定义出两个子类FrSyncNonfrSync来实现公平锁和非公平锁。此外,ReentrantReadWriteLock内部包含读和写两把锁,这两把锁都是由Sync来实现的。区别在于读锁支持共享,即多个线程可以同时加读锁成功,而写锁是互斥的,即只能有一个线程加锁成功。

乐观锁和悲观锁(定义及使用场景 )

乐观锁总是假设最好的情况 ,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。 悲观锁:悲观锁总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁

加分回答 两种锁的使用场景 乐观锁: Atomic原子类GIT,SVN,CVS代码版本控制管理器,就是一个乐观锁使用很好的场景,例如:A、B程序员,同时从SVN服务器上下载了code.html文件,当A完成提交后,此时B再提交,那么会报版本冲突,此时需要B进行版本处理合并后,再提交到服务器。这其实就是乐观锁的实现全过程。如果此时使用的是悲观锁,那么意味者所有程序员都必须一个一个等待操作提交完,才能访问文件,这是难以接受的。 悲观锁: 悲观锁的好处在于可以减少并发,但是当并发量非常大的时候,由于锁消耗资源、锁定时间过长等原因,很容易导致系统性能下降,资源消耗严重。因此一般我们可以在并发量不是很大,并且出现并发情况导致的异常用户和系统都很难以接受的情况下,会选择悲观锁进行。

LockSupport是用来创建锁和其他同步类的基本线程阻塞原语

中断

每个线程对象中都有一个标识,用于标识线程是否被中断;该标识位为true表示中断,为false表示未中断;通过调用线程对象的interrupt方法将线程的标识位设为true;可以在别的线程中调用,也可以在自己的线程中调用

使用中断标识停止线程,三种中断标识停止线程的方式

  • 通过Thread自带的中断API
  • 使用volatile变量
  • 使用AtomicBoolean

中断为true后,并不是立刻stop程序

传统的synchronized和Lock实现等待唤醒通知的约束
  • 线程先要获得并持有锁,必须在锁块(synchronized或lock)中
  • 必须要先等待后唤醒,线程才能够被唤醒
LockSupport类中的park等待和unpark唤醒
  • LockSupport是用来创建锁和其他同步类的基本线程阻塞原语。
  • LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(permit),permit只有两个值1和零,默认是零。
  • 可以把许可看成是一种(0,1)信号量(Semaphore),但与Semaphore不同的是,许可的累加上限是1。

34、说说你对AQS的理解

模板方法、同步队列、同步状态

  • 简单来说AQS就是维护了一个共享资源,然后使用队列来保证线程获取资源的一个过程
  • AQS的工作流程:当被请求的共享资源空闲,那么直接将请求资源的线程设置为有效线程,如果共享资源不空闲的话,那么AQS就给我提供了一套阻塞队列等待以及唤醒线程时的锁分配的机制
枚举含义
0当一个 Node 被初始化的时候的默认值
CANCELLED1,表示线程获取锁的请求已经取消了
SIGNAL-1,表示线程已经准备好了,就等资源释放
CONDITION-2,表示节点在等待队列中,节点线程等待唤醒
PROPAGATE-3,当前线程处在 SHARED 情况下,该字段才会使用

这个队列是通过CLH实现的,该队列是一个双向队列,有node节点组成,每个node节点包含等待状态,线程信息,前驱节点,后继节点等信息,同时AQS还维护了两个指针HeadTail,分别指向队列的头部和尾部

加锁流程

  • 如果state等于0,则会通过cas的方式修改state值,修改成功则获取到锁,将当前线程设为持有锁线程。(公平锁需要判断队列是否有其他线程)
  • 如果state不等于0,则判断当前线程是否持有锁,如果持有锁则将state+1
  • 如果获取锁失败,则新建node节点并且初始化tailhead,并且自旋获取锁,判断前驱节点是否为头结点
  • 如果前驱节点为头结点,则会再次尝试cas修改state值,修改成功则会获取到锁
  • 如果前驱节点不为头节点或者修改失败,则获取前驱节点状态,判断是否需要阻塞当前线程。如果状态为SINGAL则阻塞当前线程,如果为CANCELLED则向前遍历移除cancel节点,如果为其他状态将前驱节点状态设为SINGAL

释放锁流程

  • 减少state
  • 判断执行线程是否为持有锁的线程,不是则抛出异常
  • 如果state等于0,则代表锁被释放,将持有锁的线程设为null

唤醒线程流程

  • 如果锁被释放,则会唤醒头结点的下一个节点
  • 如果头结点的下一个节点为空或者状态为CANCELLED,则会从后往前找到第一个不为CANCELLED的节点唤醒。

AQS(AbstractQueuedSynchronizer)是队列同步器,是用来构建锁的基础框架,Lock实现类都是基于AQS实现的。AQS是基于模板方法模式进行设计的,所以锁的实现需要继承AQS并重写它指定的方法。AQS内部定义了一个FIFO的队列来实现线程的同步,同时还定义了同步状态来记录锁的信息。 AQS的模板方法,将管理同步状态的逻辑提炼出来形成标准流程,这些方法主要包括:独占式获取同步状态、独占式释放同步状态、共享式获取同步状态、共享式释放同步状态。以独占式获取同步状态为例,它的大致流程是:

  • 尝试以独占方式获取同步状态。
  • 如果状态获取失败,则将当前线程加入同步队列。
  • 自旋处理同步状态,如果当前线程位于队头,则唤醒它并让它出队,否则使其进入阻塞状态。 其中,有些步骤无法在父类确定,则提炼成空方法留待子类实现。例如,第一步的尝试操作,对于公平锁和非公平锁来说就不一样,所以子类在实现时需要按照场景各自实现这个方法。 AQS的同步队列,是一个双向链表,AQS则持有链表的头尾节点。对于尾节点的设置,是存在多线程竞争的,所以采用CAS的方式进行修改。对于头节点设置,则一定是拿到了同步状态的线程才能处理,所以修改头节点不需要采用CAS的方式AQS的同步状态,是一个int类型的整数,它在表示状态的同时还能表示数量。通常情况下,状态为0时表示无锁,状态大于0时表示锁的重入次数。另外,在读写锁的场景中,这个状态标志既要记录读锁又要记录写锁。于是,锁的实现者就将状态表示拆成高低两部分,高位存读锁、低位存写锁。
  • 加分回答 同步状态需要在并发环境下修改,所以需要保证其线程安全。由于AQS本身就是锁的实现工具,所以不适合用锁来保证其线程安全,因为如果你用一个锁来定义另一个锁的话,那干脆直接用synchronized算了。实际上,同步状态是被volatile修饰的,该关键字可以保证状态变量的内存可见性,从而解决了线程安全问题。

35、单例模式了解吗手写一下,解释双检锁实现单例的原理

  • 双检锁
public class SingeMode {
    // 饿汉式
//    private static SingeMode instance = new SingeMode();
​
    // 懒汉
    private volatile static SingeMode instance;
​
    private SingeMode() {}
​
//    static { // 在静态代码块中,创建单例对象
//        instance = new SingeMode();
//    }
​
    // 每个线程在想获得类的实例时候,执行getInstance()方法都要进行同步。而其实这个方法只执行一次实例化代码就够了,后面的想获得该类实例,直接return
//    public static synchronized SingeMode getInstance() {
    public static SingeMode getInstance() {
        if (instance == null) {
            synchronized (SingeMode.class){
                if (instance == null) {
                    instance = new SingeMode();
                }
            }
        }
        return instance;
    }
}
  • 静态内部类
// 静态内部类完成, 推荐使用
class Singleton {
    private static volatile Singleton instance;
​
    //构造器私有化
    private Singleton() {}
    //写一个静态内部类,该类中有一个静态属性 Singleton
    private static class SingletonInstance {
        private static final Singleton INSTANCE = new Singleton();
    }
    //提供一个静态的公有方法,直接返回SingletonInstance.INSTANCE
    public static synchronized Singleton getInstance() {
        return Singleton.instance;
    }
}
  • 枚举

36、请你说说线程和协程的区别

  1. 线程是操作系统的资源,线程的创建、切换、停止等都非常消耗资源,而创建协程不需要调用操作系统的功能,编程语言自身就能完成,所以协程也被称为用户态线程,协程比线程轻量很多;
  2. 线程在多核环境下是能做到真正意义上的并行,而协程是为并发而产生的;
  3. 一个具有多个线程的程序可以同时运行几个线程,而协同程序却需要彼此协作的运行
  4. 线程进程都是同步机制,而协程则是异步
  5. 线程是抢占式,而协程是非抢占式的,所以需要用户自己释放使用权来切换到其他协程,因此同一时间其实只有一个协程拥有运行权,相当于单线程的能力;
  6. 操作系统对于线程开辟数量限制在千的级别,而协程可以达到上万的级别。

37、请你说说JUC

原子类、锁、线程池、并发容器、同步工具

JUCjava.util.concurrent的缩写,这个包是JDK 1.5提供的并发包,包内主要提供了支持并发操作的各种工具。这些工具大致分为如下5类:原子类、锁、线程池、并发容器、同步工具。

  • 原子类 从JDK 1.5开始,并发包下提供了atomic子包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。在atomic包里一共提供了17个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新引用类型、原子更新属性、原子更新数组。
  • 锁 从JDK 1.5开始,并发包中新增了Lock接口以及相关实现类用来实现锁功能,它提供了与synchronized关键字类似的同步功能,只是在使用时需要显式地获取和释放锁。虽然它缺少了隐式获取释放锁的便捷性,但是却拥有了多种synchronized关键字所不具备的同步特性,包括:可中断地获取锁、非阻塞地获取锁、可超时地获取锁。
  • 线程池 从JDK 1.5开始,并发包下新增了内置的线程池。其中,ThreadPoolExecutor类代表常规的线程池,而它的子类ScheduledThreadPoolExecutor对定时任务提供了支持,在子类中我们可以周期性地重复执行某个任务,也可以延迟若干时间再执行某个任务。此外,Executors是一个用于创建线程池的工具类,由于该类创建出来的是带有无界队列的线程池,所以在使用时要慎重。
  • 并发容器JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。第一类是以降低锁粒度来提高并发性能的容器, 它们的类名以Concurrent开头,如ConcurrentHashMap。第二类是采用写时复制技术实现的并发容器, 它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList。第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue
  • 同步工具JDK 1.5开始,并发包下新增了几个有用的并发工具类,一样可以保证线程安全。其中,Semaphore类代表信号量,可以控制同时访问特定资源的线程数量CountDownLatch类则允许一个或多个线程等待其他线程完成操作CyclicBarrier可以让一组线程到达一个屏障时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。

38、Java哪些地方使用了CAS

原子类、AQS、并发容器

  • 原子类,以AtomicInteger为例,它的内部提供了诸多原子操作的方法。如原子替换整数值、增加指定的值、加1,这些方法的底层便是采用操作系统提供的CAS原子指令来实现的。
  • AQS,在向同步队列的尾部追加节点时,它首先会以CAS的方式尝试一次,如果失败则进入自旋状态,并反复以CAS的方式进行尝试。此外,在以共享方式释放同步状态时,它也是以CAS方式对同步状态进行修改的。
  • 对于并发容器,以ConcurrentHashMap为例,它的内部多次使用了CAS操作。在初始化数组时,它会以CAS的方式修改初始化状态,避免多个线程同时进行初始化。在执行put方法初始化头节点时,它会以CAS的方式将初始化好的头节点设置到指定槽的首位,避免多个线程同时设置头节点。在数组扩容时,每个线程会以CAS方式修改任务序列号来争抢扩容任务,避免和其他线程产生冲突。在执行get方法时,它会以CAS的方式获取头指定槽的头节点,避免其他线程同时对头节点做出修改

加分回答

CAS的实现离不开操作系统原子指令的支持,Java中对原子指令封装的方法集中在Unsafe类中,包括:原子替换引用类型原子替换int型整数原子替换long型整数。这些方法都有四个参数:var1、var2、var4、var5,其中var1代表要操作的对象,var2代表要替换的成员变量,var4代表期望的值,var5代表更新的值。

public final native boolean compareAndSwapObject( Object var1, long var2, Object var4, Object var5);`
​
public final native boolean compareAndSwapInt( Object var1, long var2, int var4, int var5);
​
 public final native boolean compareAndSwapLong( Object var1, long var2, long var4, long var6);

39、并发编程的三个重要特性

  • 原子性:一次操作或多次操作,要么所有操作都全部指向并且不被外界打扰,要么都不执行。通过synchronized保证
  • 可见性(一致性):当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到最新的值,通过volatitle关键字保证共享变量的可见性
  • 有序性:代码在执行的过程中的先后顺序,通过jvm优化重排后,未必会按照我们的顺序执行,通过volatile可以禁止指令进行重排

40、说说volatile的用法及原理

特性、内存语义、实现机制

  • volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile使用恰当的话,它比synchronized的执行成本更低,因为它不会引起线程上下文的切换和调度。

  • 简而言之,volatile变量具有以下特性:

    • 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
    • 原子性:对单个volatile变量的读写具有原子性,对“volatile变量++”这种复合操作则不具有原子性
  • volatile通过影响线程的内存可见性来实现上述特性,它具备如下的内存语义。其中,JMM是指Java内存模型,而本地内存只是JMM的一个抽象概念,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。在本文中,大家可以将其简单理解为缓存。

    • 写内存语义:当写一个volatile变量时,JMM会把该线程本地内存中的共享变量的值刷新到主内存中。
    • 读内存语义:当读一个volatile变量时,JMM会把该线程本地内存置为无效,使其从主内存中读取共享变量。
  • volatile的底层是采用内存屏障来实现的,就是在编译器生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障就是一段与平台相关的代码,Java中的内存屏障代码都在Unsafe类中定义,共包含三个方法:LoadFence()storeFence()fullFence()

  • 加分回答 从内存语义的角度来说,volatile读/写,与锁的获取/释放具有相同的内存效果。即volatile读与锁的获取有相同的内存语义,volatile写与锁的释放有相同的内存语义。 volatile只能保证单个变量读写的原子性,而锁则可以保证对整个临界区的代码执行具有原子性。所以,在功能上锁比volatile更强大,在可伸缩性和性能上volatile更优优势。

理解指令重排序

  1. 指令重排序,就是出于优化考虑,CPU执行指令的顺序跟程序员自己编写的顺序不一致
  2. 就好比一份试卷,题号是老师规定的,是程序员规定的,但是考生(CPU)可以先做选择,也可以先做填空
  3. 单线程环境里面可以确保程序最终执行结果和代码顺序执行的结果一致
  4. 处理器在进行重排序时必须要考虑指令之间的数据依赖性
  5. 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的执行顺序,
  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

CAS:compare and set(比较并交换) : 底层自旋锁 + Unsafe 类

CAS的全称为Compare-And-Swap,它是一条CPU并发原语。

它的功能是判断内存某个位置的值是否为预期值,如果是则更改为新的值,这个过程是原子的。

CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用UnSafe类中的CAS方法,JVM会帮我们实现出CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

UnSafe.getAndAddInt()源码解释:

  • var1 AtomicInteger对象本身。
  • var2 该对象值得引用地址。
  • var4 需要变动的数量。
  • var5是用过var1,var2找出的主内存中真实的值。
  • 用该对象当前的值与var5比较:
  • 如果相同,更新var5+var4并且返回true,
  • 如果不同,继续取值然后再比较,直到更新完成。 假设线程A和线程B两个线程同时执行getAndAddInt操作(分别跑在不同CPU上) :
  1. Atomiclnteger里面的value原始值为3,即主内存中Atomiclnteger的value为3,根据JMM模型,线程A和线程B各自持有一份值为3的value的副本分别到各自的工作内存。
  2. 线程A通过getIntVolatile(var1, var2)拿到value值3,这时线程A被挂起。
  3. 线程B也通过getintVolatile(var1, var2)方法获取到value值3,此时刚好线程B没有被挂起并执行compareAndSwapInt方法比较内存值也为3,成功修改内存值为4,线程B打完收工,一切OK。
  4. 这时线程A恢复,执行compareAndSwapInt方法比较,发现自己手里的值数字3和主内存的值数字4不一致,说明该值己经被其它线程抢先一步修改过了,那A线程本次修改失败,只能重新读取重新来一遍了。
  5. 线程A重新获取value值,因为变量value被volatile修饰,所以其它线程对它的修改,线程A总是能够看到,线程A继续执行compareAndSwaplnt进行比较替换,直到成功。 底层汇编

synchronized采用的是悲观锁,是一种独占锁,独占锁就意味着 其他线程只能依靠阻塞就是其他线程不停的询问来等待线程释放锁。而在 CPU 转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起 CPU 频繁的上下文切换导致效率很低

CAS采用的是一种乐观锁的机制,它不会阻塞任何线程,所以在效率上,它会比 synchronized 要高。所谓乐观锁就是:每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

CAS缺点

1.循环时间长开销很大 有个do while,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。 2、只能保证一个共享变量的原子操作 当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。 3、引出来ABA问题

公平锁/非公平锁/可重入锁/递归锁/自旋锁谈谈你的理解?请手写一个自旋锁

公平锁:是指多个线程按照申请锁的顺序来获取锁,类似排队打饭,先来后到。

非公平锁:是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发的情况下,有可能会造成优先级反转或者饥饿现象

并发包中ReentrantLock的创建可以指定构造函数的boolean类型来得到公平锁或非公平锁,默认是非公平锁

对应Synchronized而言,也是一种非公平锁

公平锁,就是很公平,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到自己 非公平锁

非公平锁比较粗鲁,上来就直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。

可重入锁(也叫做递归锁)指的是同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁

就像有了家门的锁,厕所、书房、厨房就为你敞开了一样

也即是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块

ReentrantLock,synchronized 就是一个典型的可重入锁

可重入锁的最大作用就是避免死锁

自旋锁(SpinLock) 是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

使用 AtomicReference 封装 Thread ,通过 CAS算法实现线程的自旋锁

独占锁(写)/共享锁(读)/互斥锁

41、说说你对线程池的理解

核心参数、处理流程、拒绝策略

  • 线程池的主要特点为:线程复用;控制最大并发数;管理线程。

    • 第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
    • 第二:提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
    • 第三:提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控
  • 线程池可以有效地管理线程:它可以管理线程的数量,可以避免无节制的创建线程,导致超出系统负荷直至崩溃。它还可以让线程复用,可以大大地减少创建和销毁线程所带来的开销

七大参数

  1. corePoolSize:线程池中的常驻核心线程数
  • 在创建了线程池后,当有请求任务来之后,就会安排池中的线程去执行请求任务,近似理解为今日当值线程。
  • 当线程池中的线程数目达到corePoolSize后,就会把到达的任务放到缓存队列当中。
  1. maximumPoolSize:线程池能够容纳同时执行的最大线程数,此值必须大于等于1
  2. keepAliveTime:多余的空闲线程的存活时间。
  • 当前线程池数量超过corePoolSize时,当空闲时间达到keepAliveTime值时,多余空闲线程会被销毁直到只剩下corePoolSize个线程为止
  1. unitkeepAliveTime的单位。
  2. workQueue:任务队列,被提交但尚未被执行的任务。
  3. threadFactory:表示生成线程池中工作线程的线程工厂,用于创建线程一般用默认的即可。
  4. handler:拒绝策略,表示当队列满了并且工作线程大于等于线程池的最大线程数( maximumPoolSize)。
  • 线程池遵循池化思想,通过将线程和任务分离的方式达到线程复用的效果。
  • 线程池是基于生产者消费者模型设计,底层有一个线程集合和阻塞队列。
  • 当任务提交时,会判断线程数量是否大于核心线程数,小于的话则直接创建线程运行任务,并保存到线程集合中。
  • 大于的话则将任务添加到阻塞队列中,线程集合会不断从阻塞队列中取出任务运行。
  • 当阻塞队列满的时候,会判断线程数量是否大于最大线程数,小于的话则创建救急线程,大于的话则执行拒绝策略。
  • 急救线程会在取出任务时间超过keepAliveTime后进行回收,减少资源消耗

42、说说你对ThreadLocal的理解

作用、实现机制

  • ThreadLocal,即线程变量,它将需要并发访问的资源复制多份,让每个线程拥有一份资源。
  • 由于每个线程都拥有自己的资源副本,从而也就没有必要对该变量进行同步了。ThreadLocal提供了线程安全的共享机制,在编写多线程代码时,可以把不安全的变量封装进ThreadLocal。 在实现上,Thread类中声明了threadLocals变量,用于存放当前线程独占的资源ThreadLocal类中定义了该变量的类型(ThreadLocalMap),这是一个类似于Map的结构,用于存放键值对。
  • Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。
  • ThreadLocal类中还提供了setget方法,set方法会初始化ThreadLocalMap将其绑定Thread.threadLocals,从而将传入的值绑定到当前线程。在数据存储上,传入的值将作为键值对的value,而key则是ThreadLocal对象本身(this)。get方法没有任何参数,它会以当前ThreadLocal对象(this)为key,从Thread.threadLocals中获取与当前线程绑定的数据。
  • 加分回答 注意,ThreadLocal不能替代同步机制,两者面向的问题领域不同。同步机制是为了同步多个线程对相同资源的并发访问,是多个线程之间进行通信的有效方式。而ThreadLocal是为了隔离多个线程的数据共享,从根本上避免多个线程之间对共享资源(变量)的竞争,也就不需要对多个线程进行同步了。 一般情况下,如果多个线程之间需要共享资源,以达到线程之间的通信功能,就使用同步机制。如果仅仅需要隔离多个线程之间的共享冲突, 则可以使用ThreadLocal

补充:AtomicReference

集合

43、请说说你对Java集合的了解

Set、List、Quque、Map

  • Java中的集合类分为4大类,分别由4个接口来代表,它们是SetListQueueMap。其中,SetListQueue都继承自Collection接口。

    • Set代表无序的、元素不可重复的集合。
    • List代表有序的、元素可以重复的集合。
    • Queue代表先进先出(FIFO)的队列。
    • Map代表具有映射关系(key-value)的集合。
  • Java提供了众多集合的实现类,它们都是这些接口的直接或间接的实现类,其中比较常用的有:HashSetTreeSetArrayListLinkedListArrayDequeHashMapTreeMap等。

  • 加分回答 上面所说的集合类的接口或实现,都位于java.util包下,这些实现大多数都是非线程安全的。虽然非线程安全,但是这些类的性能较好。如果需要使用线程安全的集合类,则可以利用Collections工具类,该工具类提供的synchronizedXxx()方法,可以将这些集合类包装成线程安全的集合类java.util包下的集合类中,也有少数的线程安全的集合类,例如VectorHashtable,它们都是非常古老的API。虽然它们是线程安全的,但是性能很差,已经不推荐使用了。 从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。

  • 第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如ConcurrentHashMap

  • 第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList

  • 第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue

44、你知道哪些线程安全的集合?

Collectionsjava.util.concurrent (JUC)

  • 上面所说的集合类的接口或实现,都位于java.util包下,这些实现大多数都是非线程安全的。虽然非线程安全,但是这些类的性能较好。如果需要使用线程安全的集合类,则可以利用Collections工具类,该工具类提供的synchronizedXxx()方法,可以将这些集合类包装成线程安全的集合类java.util包下的集合类中,也有少数的线程安全的集合类,例如VectorHashtable,它们都是非常古老的API。虽然它们是线程安全的,但是性能很差,已经不推荐使用了。 从JDK 1.5开始,并发包下新增了大量高效的并发的容器,这些容器按照实现机制可以分为三类。
  • 第一类是以降低锁粒度来提高并发性能的容器,它们的类名以Concurrent开头,如ConcurrentHashMap
  • 第二类是采用写时复制技术实现的并发容器,它们的类名以CopyOnWrite开头,如CopyOnWriteArrayList
  • 第三类是采用Lock实现的阻塞队列,内部创建两个Condition分别用于生产者和消费者的等待,这些类都实现了BlockingQueue接口,如ArrayBlockingQueue
  • 加分回答 Collections还提供了如下三类方法来返回一个不可变的集合,这三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。通过Collections提供的三类方法,可以生成“只读”的CollectionMapemptyXxx():返回一个空的不可变的集合对象 singletonXxx():返回一个只包含指定对象的不可变的集合对象 unmodifiableXxx():返回指定集合对象的不可变视图

45、说说你对ArrayList的理解

数组实现、默认容量10、每次扩容1.5倍 (x + x >> 2

  • ArrayList是基于数组实现的,它的内部封装了一个Object[]数组。 通过默认构造器创建容器时,该数组先被初始化为空数组,之后在首次添加数据时再将其初始化成长度为10的数组。我们也可以使用有参构造器来创建容器,并通过参数来显式指定数组的容量,届时该数组被初始化为指定容量的数组。 如果向ArrayList中添加数据会造成超出数组长度限制,则会触发自动扩容,然后再添加数据。扩容就是数组拷贝,将旧数组中的数据拷贝到新数组里,而新数组的长度为原来长度的1.5ArrayList支持缩容,但不会自动缩容,即便是ArrayList中只剩下少量数据时也不会主动缩容。如果我们希望缩减ArrayList的容量,则需要自己调用它的trimToSize()方法,届时数组将按照元素的实际个数进行缩减
  • 加分回答 SetListQueue都是Collection的子接口,它们都继承了父接口的iterator()方法,从而具备了迭代的能力。但是,相比于另外两个接口,List还单独提供了listIterator()方法,增强了迭代能力。iterator()方法返回Iterator迭代器,listIterator()方法返回ListIterator迭代器,并且ListIteratorIterator的子接口。ListIteratorIterator的基础上,增加了向前遍历的支持,增加了在迭代过程中修改数据的支持。

failFast VS failSafe

  • failFast:遍历同时不能修改,尽快失败
private class Itr implements Iterator<E> {
    int cursor;       // index of next element to return
    int lastRet = -1; // index of last element returned; -1 if no such
    int expectedModCount = modCount;
}
final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}
  • failSafe:遍历的同时可以修改,原理是读写分离

遍历和添加不同数组,二种互不干扰

    public class CopyOnWriteArrayList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
        private static final long serialVersionUID = 8673264195747942595L;
​
        /** The lock protecting all mutators */
        final transient ReentrantLock lock = new ReentrantLock();
​
        /** The array, accessed only via getArray/setArray. */
        private transient volatile Object[] array;
    }
​
  // 添加
  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();
        }
    }
    
     final void setArray(Object[] a) {
        array = a;
    }
static final class COWIterator<E> implements ListIterator<E> {
    /** Snapshot of the array */
    private final Object[] snapshot;
    /** Index of element to be returned by subsequent call to next.  */
    private int cursor;
​
    private COWIterator(Object[] elements, int initialCursor) {
        cursor = initialCursor;
        snapshot = elements;
    }
}
// 将指定的元素追加到此列表的末尾。
public boolean add(E e) {
    //添加元素之前,先调用ensureCapacityInternal方法
    ensureCapacityInternal(size + 1);  // Increments modCount!!
    //这里看到ArrayList添加元素的实质就相当于为数组赋值
    elementData[size++] = e;
    return true;
}
​
//得到最小扩容量
private void ensureCapacityInternal(int minCapacity) {
    if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
        // 获取默认的容量和传入参数的较大值
        minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
    }
​
    ensureExplicitCapacity(minCapacity);
}
​
//判断是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {
    modCount++;
​
    // overflow-conscious code
    if (minCapacity - elementData.length > 0)
        //调用grow方法进行扩容,调用此方法代表已经开始扩容了
        grow(minCapacity);
}
​
// 要分配的最大数组大小
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
​
// ArrayList扩容的核心方法。
private void grow(int minCapacity) {
    // oldCapacity为旧容量,newCapacity为新容量
    int oldCapacity = elementData.length;
    //将oldCapacity 右移一位,其效果相当于oldCapacity /2,
    //我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
    int newCapacity = oldCapacity + (oldCapacity >> 1);
    //然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
    if (newCapacity - minCapacity < 0)
        newCapacity = minCapacity;
    // 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,
    //如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // minCapacity is usually close to size, so this is a win:
    elementData = Arrays.copyOf(elementData, newCapacity);
}

46、请你说说ArrayList和LinkedList的区别

数据结构、访问效率 、查询快慢如果按内容查都是O(n),需要用更快的数据结构

  • ArrayList的实现是基于数组

    • 基于数组,需要连续内存
    • 随机访问快(指根据下标访问)
    • 尾部插入、删除性能可以,其他部分插入、删除都会移动数据,因此性能会低
    • 可以利用cpu缓存,局部性原理
  • LinkedList的实现是基于双向链表。

    • 无需连续内存
    • 随机访问慢
    • 头尾插入删除性能高
    • 占用内存多
  • 局部性原理--空间占用(CPU缓存)

  • 对于随机访问ArrayList要优于LinkedList,ArrayList可以根据下标以O(1)时间复杂度对元素进行随机访问,而LinkedList每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是O(N)

  • 对于插入和删除操作,LinkedList要优于ArrayList,因为当元素被添加到LinkedList任意位置的时候,不需要像ArrayList那样重新计算大小或者是更新索引

  • LinkedListArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

47、请你说说HashMap底层原理

数据结构、put()流程、扩容机制

为何要用红黑树,为何一上来不树化,树化阈值为何是8,何时会树化,何时会退化未链表?

  • 1.7 数组 + 链表
  • JDK8中,HashMap底层是采用“数组+链表+红黑树”来实现的。 HashMap是基于哈希算法来确定元素的位置(槽)的,当我们向集合中存入数据时,它会计算传入的Key的哈希值,并利用哈希值取余来确定槽的位置。如果元素发生碰撞,也就是这个槽已经存在其他的元素了,则HashMap会通过链表将这些元素组织起来。如果碰撞进一步加剧,某个链表的长度达到了8,则HashMap会创建红黑树来代替这个链表,从而提高对这个槽中数据的查找的速度。 HashMap中,数组的默认初始容量为16,这个容量会以2的指数进行扩容。具体来说,当数组中的元素达到一定比例的时候HashMap就会扩容,这个比例叫做负载因子,默认为0.75自动扩容机制,是为了保证HashMap初始时不必占据太大的内存,而在使用期间又可以实时保证有足够大的空间。采用2的指数进行扩容,是为了利用位运算,提高扩容运算的效率。

树化意义

  • 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略

    • hash 表的查找,更新的时间复杂度是 O(1),而红黑树的查找,更新的时间复杂度是 O(log_2⁡n )TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
    • hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小

树化规则

  • 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化

退化规则

  • 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
  • 情况2:remove 树节点时,若 rootroot.leftroot.rightroot.left.left 有一个为 null ,也会退化为链表
扩容

put 流程

  1. HashMap 是懒惰创建数组的,首次使用才创建数组

  2. 计算索引(桶下标)

  3. 如果桶下标还没人占用,创建 Node 占位返回

  4. 如果桶下标已经有人占用

    1. 已经是 TreeNode 走红黑树的添加或更新逻辑
    2. 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
  5. 返回前检查容量是否超过阈值,一旦超过进行扩容

1.7 与 1.8 的区别

  1. 链表插入节点时,1.7 是头插法,1.8 是尾插法
  2. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
  3. 1.8 在扩容计算 Node 索引时,会优化

扩容(加载)因子为何默认是 0.75f

  1. 在空间占用与查询时间之间取得较好的权衡
  2. 大于这个值,空间节省了,但链表就会比较长影响性能
  3. 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

多线程下会有啥问题?

  1. 扩容死链(1.7)
  2. 数据错乱(1.7,1.8)

作为key的对象,必须实现hashCode和equals,并且key的内容不能修改

  • put()流程 put()方法的执行过程中,主要包含四个步骤:

    1. 判断数组,若发现数组为空,则进行首次扩容(HashMap是懒惰创建数组的,首次使用才创建数组)
    2. 计算索引(桶下标)
    3. 判断头节点,若发现头节点为空,则新建链表节点,存入数组。
    4. 判断头节点,若发现头节点非空,则将元素插入槽内
    5. . 插入元素后,判断元素的个数,若发现超过阈值则再次扩容
  • 其中,第4步又可以细分为如下三个小步骤:

    1. 若元素的key与头节点一致,则直接覆盖头节点。
    2. 若元素为TreeNode树型节点,走红黑树的添加和更新逻辑。
    3. 若元素为链表节点,则将元素追加到链表中。追加后,需要判断链表长度以决定是否转为红黑树。若链表长度达到8、数组容量未达到64,则扩容。若链表长度达到8、数组容量达到64,则转为红黑树。
  • 扩容机制 向HashMap中添加数据时,有三个条件会触发它的扩容行为

    1. 如果数组为空,则进行首次扩容
    2. 将元素接入链表后,如果链表长度达到8,并且数组长度小于64,则扩容
    3. 添加后,如果数组中元素超过阈值,即比例超出限制(默认为0.75),则扩容。 并且,每次扩容时都是将容量翻倍,即创建一个2倍大的新数组,然后再将旧数组中的数组迁移到新数组里。由于HashMap中数组的容量为2^N,所以可以用位移运算计算新容量,效率很高。
  • 加分回答 HashMap是非线程安全的,在多线程环境下,多个线程同时触发HashMap的改变时,有可能会发生冲突。所以,在多线程环境下不建议使用HashMap,可以考虑使用CollectionsHashMap转为线程安全的HashMap,更为推荐的方式则是使用ConcurrentHashMap

索引如何计算?hashCode都有了,为何还要提供hash()方法?数组容量为何是2n次幂

索引计算方法

  • 首先,计算对象的 hashCode()

  • 再进行调用 HashMaphash() 方法进行二次哈希

    • 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
  • 最后 & (capacity – 1) 得到索引

数组容量为何是 2 的 n 次幂

  • 计算索引时效率更高:如果是 2n 次幂可以使用位与运算代替取模(97 % 16,97 & (16 - 1))
  • 扩容时重新计算索引效率更高hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap

注意

  • 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
// hash分布更均匀
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable(质数分散性好)

48、请你说说HashMap和Hashtable的区别

得分点 线程安全、null

  • HashMapHashtable都是典型的Map实现,它们的区别在于是否线程安全,是否可以存入null值。
  • Hashtable在实现Map接口时保证了线程安全性,而HashMap则是非线程安全的。所以,Hashtable的性能不如HashMap,因为为了保证线程安全它牺牲了一些性能
  • Hashtable不允许存入null,无论是以null作为keyvalue,都会引发异常。而HashMap是允许存入null的,无论是以null作为keyvalue,都是可以的。
  • 加分回答 虽然Hashtable是线程安全的,但仍然不建议在多线程环境下使用Hashtable。因为它是一个古老的API,从Java 1.0开始就出现了,它的同步方案还不成熟,性能不好。如果要在多线程环下使用HashMap,建议使用ConcurrentHashMap。它不但保证了线程安全,也通过降低锁的粒度提高了并发访问时的性能

49、请你说说ConcurrentHashMap

数组+链表+红黑树、锁的粒度

  • JDK8中,ConcurrentHashMap的底层数据结构与HashMap一样,也是采用“数组+链表+红黑树”的形式。同时,它又采用锁定头节点的方式降低了锁粒度,以较低的性能代价实现了线程安全。底层数据结构的逻辑可以参考HashMap的实现,下面我重点介绍它的线程安全的实现机制。
  1. 初始化数组或头节点时,ConcurrentHashMap并没有加锁,而是CAS的方式进行原子替换(原子操作,基于Unsafe类的原子操作API)。
  2. 插入数据时会进行加锁处理,但锁定的不是整个数组,而是槽中的头节点。所以,ConcurrentHashMap中锁的粒度是槽,而不是整个数组,并发的性能很好。
  3. 扩容时会进行加锁处理,锁定的仍然是头节点。并且,支持多个线程同时对数组扩容,提高并发能力。每个线程需先以CAS操作抢任务,争抢一段连续槽位的数据转移权。抢到任务后,该线程会锁定槽内的头节点,然后将链表或树中的数据迁移到新的数组里。
  4. 查找数据时并不会加锁,所以性能很好。另外,在扩容的过程中,依然可以支持查找操作。如果某个槽还未进行迁移,则直接可以从旧数组里找到数据。如果某个槽已经迁移完毕,但是整个扩容还没结束,则扩容线程会创建一个转发节点存入旧数组,届时查找线程根据转发节点的提示,从新数组中找到目标数据。
  • 加分回答 ConcurrentHashMap实现线程安全的难点在于多线程并发扩容,即当一个线程在插入数据时,若发现数组正在扩容,那么它就会立即参与扩容操作,完成扩容后再插入数据到新数组。在扩容的时候,多个线程共同分担数据迁移任务,每个线程负责的迁移数量是 (数组长度 >>> 3) / CPU核心数。 也就是说,为线程分配的迁移任务,是充分考虑了硬件的处理能力的。多个线程依据硬件的处理能力,平均分摊一部分槽的迁移工作。另外,如果计算出来的迁移数量小于16,则强制将其改为16,这是考虑到目前服务器领域主流的CPU运行速度,每次处理的任务过少,对于CPU的算力也是一种浪费。

50、请你说说List与Set的区别

list和set都是接口collection的子接口,list代表有序的可重复的集合,每个元素都有对应的顺序索引,可以通过索引来访问指定位置的集合元素。而set表示无序,不可重复的集合元素。但是它有支持排序的实现类treeset,treeset可以确保元素处于排序状态,并支持自然排序和定制排序两种方式,treeset是非线程安全的,内部元素的值不能为null

补:线程和进程的区别

1.进程有【独立的地址空间】,线程有自己的堆栈和局部变量,但是线程之间没有单独的地址空间

2.进程和线程切换时,需要切换进程和线程的上下文,【进程的上下文切换时间开销】远远大于【线程上下文切换时间】,耗费资源较大,效率要差一些

3.【进程的并发性较低,线程的并发性较高】 4.每个独立的进程有一个程序运行的入口,顺序执行序列和程序的出口,线程不能独立执行

5.系统在运行的时候会为【每个进程分配不同的内存空间】,对线程而言,除CPU外,系统不会为线程分配内存

6.一个【进程奔溃后】,在保护模式下不会对其他进程产生影响,但是一个线程奔溃后整个进程都会死掉,所以多进程比多线程更健壮。

①进程是一个“执行中的程序”,是系统进行资源分配和调度独立单位; ②线程是进程的一个实体,一个进程中拥有多个线程,线程之间共享地址空间和其他资源; ③与进程不同的是同类的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈,所以系统在产生一个线程,或是在各个线程之间作切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。

协程是线程的抽象单位,减少了线程切换过程中的资源代价