Java基础

149 阅读11分钟

java特性

Java的特性

  1. 抽象:现实生活中的事物被抽象成对象,把具有相同属性和行为的对象被抽象成类,再从具有相同属性和行为的类中抽象出父类。image.png

  2. 封装:隐藏对象的属性和实现细节,仅仅对外公开接口。

    • 封装的优点
      1. 便于使用者正确、方便的使用系统,防止使用者错误修改系统属性;
      2. 有助于建立各个系统之间的松耦合关系;
      3. 提高软件的可重用性;
      4. 降低了大型系统的风险,即便整个系统不成功,个别独立的子系统有可能还有价值。
    • 封装的两大原则
      1. 把尽可能多的东西封装起来,对外提供简洁的接口
      2. 把所有的属性封装起来
  3. 继承:子类和父类之间的继承关系,子类可以获取到父类的属性和方法。

    注:关于子类能否继承父类的私有方法? 从语言角度上说:JDK官方文档明确说明子类不能继承父类的私有方法;但从内存角度来说,jvm在实例化子类对象之前,会先在内存中创建一个父类对象,然后在父类对象外部放上子类独有的属性,两者合起来形成一个子类对象。所以子类确实拥有父类所有的属性和方法,但是父类中的私有方法子类无法访问。

  4. 多态:java语言允许某个类型的引用变量引用子类的实例,而且可以对这个引用变量进行类型转换。

面向对象的三个特性?

  1. 封装:是对象功能内聚的表现形式,对属性、数据、敏感行为实现隐藏,对属性的访问和修改必须通过公共接口实现。封装使对象关系变得简单。
  2. 继承:用来扩展一个类,子类继承父类的部分属性和行为使模块具有复用性,继承是“is a”的关系。
  3. 多态:以封装和继承为基础,根据运行时对象实际类型使同一行为具有不同表现形式。因为在编译时无法确定最终调用的方法体,在运行时由JVM动态绑定,调用合适的重写方法。

面向过程,面向对象,面向切面都是啥

  1. 面向过程让计算机有步骤地顺序做一件事,是过程化思维,使用面向过程语言开发大型项目,模块之间耦合严重
  2. 面向对象区别与面向过程,强调高内聚低耦合,对现实事物进行抽象并映射成对象,定义共性行为,再解决实际问题。
  3. 面向切面在运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。(封装性并降低耦合)
    • 切面:切入到指定类指定方法的代码片段称为切面
    • 切入点:切入到哪些类、哪些方法
    • AOP做的就是把几个类共有的代码,抽取到一个切片中,等到需要时再切入对象中去,从而改变其原有的行为。AOP其实只是OOP的补充而已。OOP从横向上区分出一个个的类来,而AOP则从纵向上向对象中加入特定的代码。从技术上来说,AOP基本上是通过代理机制实现的。

重载和重写的区别?

  1. 重载:在同一个类中,对同名函数的不同实现,其参数类型/顺序/个数不同。但不能以返回值来作为标准,因为我们在调用方法时有时并不关注返回值。对于编译器来说,方法名称和参数列表构成了一个唯一的方法签名,重载在编译时就知道该调用哪个方法,因此属于静态绑定,并不属于多态
  2. 重写:在子类中,重写父类方法,要求返回相同类型或子类型,且访问权限不能变小,抛出的异常类型不能变大。

深拷贝和浅拷贝?

  • 浅拷贝:
    1. 对于基本数据类型的成员对象,因为基础数据类型是值传递的,所以是直接将属性值赋值给新的对象。基础类型的拷贝,其中一个对象修改该值,不会影响另外一个。
    2. 对于引用类型,比如数组或者类对象,因为引用类型是引用传递,所以浅拷贝只是把内存地址赋值给了成员变量,它们指向了同一内存空间。改变其中一个,会对另外一个也产生影响。
  • 深拷贝
    1. 对于基本数据类型的成员对象,因为基础数据类型是值传递的,所以是直接将属性值赋值给新的对象。基础类型的拷贝,其中一个对象修改该值,不会影响另外一个(和浅拷贝一样)。
    2. 对于引用类型,比如数组或者类对象,深拷贝会新建一个对象空间,然后拷贝里面的内容,所以它们指向了不同的内存空间。改变其中一个,不会对另外一个也产生影响。
    3. 对于有多层对象的,每个对象都需要实现 Cloneable 并重写 clone() 方法,进而实现了对象的串行层层拷贝。
    4. 深拷贝相比于浅拷贝速度较慢并且花销较大。

反射?

  • 反射是指在运行状态中,可以获得任意一个类的属性和方法,可以调用任意一个对象的方法和属性。反射破坏了封装性泛型约束

Class类的作用?

  • 在程序运行期间,Java运行时系统为所有对象维护一个运行时类型标识,这个信息会跟踪每个对象所属的类,虚拟机利用运行时类型信息选择要执行的正确方法,保存这些信息的类就是Class,可以通过类名.class对象.getClass()Class.forName(类的全限定名)来获取Class对象

对象的通用方法?

  1. equals:判断对象是否相等,默认使用==,可重写equals方法实现自定义比较逻辑。对于基本数据类型,判断值是否相等,对于引用类型,判断是否引用自同一对象
  2. hashCode:对象的散列值,等价的对象散列值一定相等,反之不然。在重写equals方法时应同时重写hashCode,保证等价的对象散列值相等
  3. toString:默认返回类名+@+数值,数值为散列值的无符号十六进制表示
  4. clone:克隆,默认用protected修饰,如果没有显式重写clone方法编译器报错时protected的访问错误了;如果重写了clone但没有让类实现Cloneable接口,将会抛出CloneNotSupported异常。默认实现的是浅拷贝,可以通过重写实现深拷贝
  5. getClass:获取对象的类类型
  6. finalize:在对象被回收时可能被调用。在finalize中重新与引用链上的对象建立关联就可以被移出回收集合不被回收
  7. 作为锁的几个方法:wait、notify、notifyAll

hashcode相同,对象一定相同吗?有没有办法实现hashcode相同,对象也相同?

  • 如果两个对象equals相等,那么这两个对象的HashCode一定也相同

  • 如果两个对象的HashCode相同,不代表两个对象就相同,只能说明这两个对象在散列存储结构中,存放于同一个位置

内部类的作用是什么,有哪些分类?

  • 内部类方法可以访问定义这个内部类的作用域中的数据,包括private。编译器会将内部类转换为常规的类文件,以$分隔外部类和内部类名。
    1. 静态内部类:只加载一次,可通过外部类名.内部类名直接访问,可以访问外部类的所有静态属性和方法。HashMap的Node节点、ReentrantLock中的Sync都是静态内部类
    2. 成员内部类:外部类的每个对象都会加载一次,不可以定义静态成员和方法,可访问外部类的所有内容
    3. 局部内部类:定义在方法内,不能声明访问修饰符,只能定义实例成员变量和实例方法
    4. 匿名内部类:只用一次的没名字的类,字节码文件以数字编号命名

接口和抽象类的异同

image.png

子类初始化顺序?

  • 父类静态代码块和静态变量->子类静态代码块和静态变量->父类普通代码块和普通变量->父类构造方法->子类普通代码块和普通变量->子类构造方法

关键字

  1. 访问控制:private、protected、public
  2. 修饰符:abstract、class、extends、implements、interface、final、synchronized、volatile、transient、static
  3. 程序控制:switch-case、if-else、for、while、break、continue、default
  4. 错误处理:try-catch-finally、throw、throws
  5. 基本数据类型
  6. 变量引用:super、this、void

default关键字的作用

  1. 在switch-case语句中,如果case没有和开关值相匹配,则可用default匹配,如果没有default,则跳出到switch外
  2. 在注解中,使用default为属性设置默认值
  3. 接口方法使用default修饰就可以有方法体

int和Integer的区别

  • Integer是int的包装类,int则是java的一种基本数据类型
    • 包装类型自动装箱/拆箱是什么?
      • 每个基本数据类型都有自己的包装类型,之间的自动转化称为拆箱和装箱。封装成包装类型主要是面向对象的设计,可以提供更方便的操作某种数据类型的方法,例如parseInt()
      • 包装类型设有缓存池,在使用valueOf()的方法时先查看是否在缓存池中,没有再通过 new Integer()在堆中创建一个新对象。(-128~127)
      • java在编译Integer i = 100 ;时,会翻译成为Integer i = Integer.valueOf(100)
  • Integer变量必须实例化后才能使用,而int变量不需要
  • Integer实际是对象的引用,当new一个Integer时,实际上是生成一个指针指向此对象;而int则是直接存储数据值 
  • Integer的默认值是null,int的默认值是0

String为什么是不可变对象?但是又可以修改它的值?

  • 在Java中String被final关键字修饰,因此是不可被继承的。存储字符串内容的char数组也通过final修饰,并且不提供修改数组的方法,因此String是不可变的。Java9中改用byte数组存放,并用coder指定编码类型(0-单字节Latin-1,1-双字节UTF-16)。主要是为了节省空间。
  • 对一个String对象的修改实际上都是创建了一个新String对象,再引用该对象。

String为什么设计成不可变的?

  1. 字符串常量池的需要:如果允许改变,那会造成改变了一个对象同时改变了来自相同字符串常量池引用的其他对象
  2. 字符串HashCode可缓存:因为字符串的HashCode常被使用,如HashMap所以通过缓存提高效率不用每次重复计算,不可变保证了hashCode不变。
  3. 安全:通过使其不可变来避免线程不安全问题。

String和StringBuilder和StringBuffer的区别

  • String字符串相加时底层通过StringBuilder实现,但每次都会新生成一个StringBuidler对象且最终通过toString方法返回拼接后的字符串。StringBuilder可解决在字符串相加时创建多个字符串占用空间多效率低下的问题,底层是未被final修饰的初始容量为16的byte数组,可自动扩容。
  • StringBuffer使用synchronized修饰,线程安全。

java集合

说下ArrayList

ArrayList是容量可变的非线程安全列表,使用数组实现,因此支持对元素的快速随机访问,但是插入和删除速度慢,扩容时会创建更大的数组(arraylist扩容就是根据旧数组和计算出的新长度copyof出新数组返回给elementData,arraylist初始是final修饰空数组长度0,当添加第一个元素时默认创建长度10的数组,之后扩容就是1.5倍进行扩容。),把原有数组复制到新数组。

  1. elementData是ArrayList的数据域,被transient修饰,序列化时会调用writeObject写入流,反序列化时调用readObject重新赋值到新对象的elementData
  2. size是当前实际大小,即为elementData大小
  3. modCount记录了ArrayList结构性变化的次数,继承自AbstractList,所有涉及结构变化的方法都会增加该值。通过比较expectModCount和modCount值是否相等,抛出ConcurrentModificationException,保证了在序列化和迭代过程中数组不会被修改,称为fail-fast机制

说一说LinkedList

  • LinkedList本质是双向链表,在插入和删除上速度更快,但是随机访问元素慢,实现了Deque接口,具有队列和栈的性质。通过附加引用的方式将零散的内存单元关联起来,内存利用率高

有删除过ArrayList中的元素吗?会有什么问题?如果相同元素是相邻的,要怎么做呢?

  • 在不同位置删除的效率不同
  • 如果有多个相同元素,会导致删除不完全(形式上讲,如果存在的话,我们删除具有最低索引的元素)。

Set有什么特点,有哪些实现?

Set不允许元素重复,常用实现有HashSet、LinkedHashSet、TreeSet

  1. HashSet:通过HashMap实现,map的key即set存储的元素,所有value都为Object类型常量,不保证有序,线程不安全。对于包装类型通过值判重,对于引用类型,通过hashCode和equals判重
  2. LinkedHashSet:继承自HashSet,通过LinkedHashMap实现,使用双向链表维护元素插入顺序
  3. TreeSet:通过TreeMap实现,添加元素到集合时按照比较规则将其插入到合适的位置,保证集合有序

TreeMap有什么特点

TreeMap基于红黑树实现,增删改查的平均和最差时间复杂度均为O(logn),最大特点是key有序,key必须实现Comparable接口或提供Comparator比较器,因此不能为null。

在排序时,如果比较器不为空,则通过比较器的compare方法,否则使用key实现的Comparable接口的compareTo方法,如果都没有则抛出异常。

HashMap

  • HashMap有什么特性?

    1. HashMap存储键值对实现快速存取,允许为null(因为对null做了特殊处理),key不可重复,重复则覆盖原值
    2. 非同步,线程不安全
    3. 无序
  • HashMap底层原理是什么?

    • 在JDK8之前底层实现是数组+链表,在JDK8改为数组+链表/红黑树,节点类型从Entry变为Node。成员变量包括存储数据的table数组、元素数量size、负载因子loadFactor。默认初始化容量是16,扩容容量必须是2的幂,默认负载因子为0.75
  • 如何计算key在table数组中的位置?

    • 在JDK8之前:对于字符串类型,调用stringHash32计算;对于其他类型,使用一个不变的随机值hashSeed和key的hashCode异或之后,再通过移位和异或,最终和表的长度与运算获得最终的地址。这样既减少了哈希冲突又比取模效率更高

    • 在JDK8:当key为null时,返回0;否则通过key的hashCode与高16位做异或运算得到在数组中的位置,这种hash计算将高位的变化扩展到低位,避免因表范围的限制,高位不会在索引计算中使用,可以减少哈希冲突。

  • HashMap产生hash冲突时如何解决

    • 采用了链地址法:每个节点都有一个next指针,构成一个单向链表

    • 哈希冲突的其他解决方式

      • 开放定址法:迁到下一个地址

      • 再哈希法:使用多个Hash函数,当产生冲突时使用第二个、第三个直到不冲突

      • 溢出区:冲突的放到溢出区

  • get函数的实现?

    • JDK8之前:

      • key为null,调用getForNullKey方法,如果size为0表示链表为空,返回null;否则遍历table[0]链表,找到key为null的节点则返回其value,否则返回null。
      • key不为null,调用getEntry方法,当size不为0,计算key的hash值,遍历对应位置的链表,如果key和hash都相等则返回该节点的value
    • JDK8:

      • 调用getNode函数,如果table数组不为空,则判断第一个节点和查找的key和hash相等则返回;否则判断其他节点,如果是TreeNode则以getTreeNode在树中查找,否则遍历链表查找,最终返回对应的节点的值或null。
  • put函数的实现

    • JDK8前
      • 如果key为null,则直接存入table[0]
      • 计算hash值,遍历对应链表,如果key存在,着更新新的值,返回旧的值,否则modCount++,然后addEntry方法增加一个节点并返回null
    • JDK8后
      • 如果table的大小为0或者为空则镜像扩容,否则计算key的对应位置,不存在则newNode方法新增一个节点。如果存在且是TreeNode的话,则调用putTreeVal增加一个数节点,并维持平衡。如果是链表,则遍历进行新增/修改工作。此时如果树的阈值超过8则通过treeifyBin方法,当table的长度大于64时则将其转换为红黑树,没超过则进行扩容
  • 扩容为什么要2的幂,怎么进行扩容的

    • 以2的幂去扩容可以用位运算取代取模运算,提高效率,其次可以减少碰撞,均匀分布
      • 使用&取代取模运算,&length-1相当于%length,提高了效率
      • length-1为奇数,最后一位为1,保证了&length-1后得到的是奇数或者偶数,如果length-1为偶数,则所有hash值都为偶数,造成一般空间的浪费
    • JDK8前
      • 如果容量大于MAXIMUM_CAPACITY,则将阈值设置为Integer的最大值,停止扩容。否则重新计算新容量,将阈值设置为newCapacity*loadFactor和MAXIMUM_CAPACITY+1的较小值,创建新的entry数组,通过transfer函数将数据转移。遍历链表,重新计算位置,使用头插法将元素转移。
    • JDK8后
      • 如果oldCap大于0且达到最大容量,将阈值设置为Integer最大值,停止扩容;当oldCap<<1不超过最大容量值,则扩大为两倍;当扩容阈值oldThr,则将其设为新容量;否则新容量设置为16,阈值为12
      • 数据的转移,如果是TreeNode,则采用split方法对树进行修整,当树的节点数小于6则转化为链表;链表时,将其拆分为hash值超过旧容量的部分和不超过的部分。对于hash&oldCap=0的部分不做调整,否则通过旧下标+旧容量计算出新下标完成迁移
  • 为什么负载因子是0.75

    • 因为这是折中的办法,0.5会造成一半空间的浪费,而1又造成无法处理其他的put,0.75是对时间和空间的均衡,能避免较多的hash冲突(实践出来的)
  • hashmap扩容容易造成死循环

    • JDK8前,使用头插法迁移元素,链表节点之间存在指针引用关系,在并发修改是容易造成死循环,JDK8使用尾插法并没有指针引用关系,解决了死循环。
  • hashmap线程不安全

    • 并发赋值被覆盖: 在 createEntry 方法中,新添加的元素直接放在头部,使元素之后可以被更快访问,但如果两个线程同时执行到此处,会导致其中一个线程的赋值被覆盖。

    • 已遍历区间新增元素丢失: 当某个线程在 transfer 方法迁移时,其他线程新增的元素可能落在已遍历过的哈希槽上。遍历完成后,table 数组引用指向了 newTable,新增元素丢失。

    • 新表被覆盖: 如果 resize 完成,执行了 table = newTable,则后续元素就可以在新表上进行插入。但如果多线程同时 resize ,每个线程都会 new 一个数组,这是线程内的局部对象,线程之间不可见。迁移完成后resize 的线程会赋值给 table 线程共享变量,可能会覆盖其他线程的操作,在新表中插入的对象都会被丢弃。

有哪些线程安全的容器

  • 使用synchronized修饰:vector、hashtable、collections.synchronizedXXX
  • 并发容器
    • CopyOnWriteArrayList:add操作使用可重入锁ReentrantLock,读写分离,写操作在副本,然后将原数组指向新数组,保持最终一致性
    • ConcurrentHashMap:1.7使用分段锁只锁住表的某一段segment。1.8使用CAS和synchronized保证线程安全的同时提高了效率。

多线程高并发

JMM是什么?有什么用?

  • JMM是Java内存模型,JMM主要是影响线程共享的内存可见性问题,Java线程之间的通信由JMM控制。
  • 所有变量存储于主内存,每个线程有自己的工作内存,保存被该线程使用的变量的主内存副本,线程对变量的操作必须在工作内存中进行,不能直接读写主内存数据。不同线程间无法直接访问对方工作内存的变量,线程通信必须经过主内存。关于工作内存和主内存的交互,JMM定义了8种原子操作: 操作 | 作用变量范围 | 作用 | | :----: | :----: | :-----------------: | | lock | 主内存 | 把变量标识为线程独占状态 | | unlock | 主内存 | 释放处于锁定状态的变量 | | read | 主内存 | 把变量从主内存传到工作内存 | | load | 工作内存 | 把read的值放入工作内存的变量副本 | | use | 工作内存 | 把工作内存的变量值传给执行引擎 | | assign | 工作内存 | 把从执行引擎接收的值赋给工作内存的变量 | | store | 工作内存 | 把工作内存的变量值传到主内存 | | write | 主内存 | 把store取到的变量值放入主内存变量 |

说下Synchronized和static Synchronized区别

一个是实例锁(锁在某一个实例对象上,如果该类是单例,那么该锁也具有全局锁的概念),一个是全局锁(该锁针对的是类,无论实例多少个对象,那么线程都共享该锁)。 实例锁对应的就是synchronized关键字。而类锁(全局锁)对应的就是static synchronized(或者是锁在该类的class或者classloader对象上)。

: 

  • static 说明了该类的一个静态资源,不管new了多少个对象,只有一份,所以对该类的所有对象都加了锁!
  • 实例锁是锁特定的实例(只要有synchronized就会去锁该实例),全局锁是锁所有的实例。 
  • synchronized是对类的当前实例(当前对象) 进行加锁,防止其他线程同时访问该类的该实例的所有synchronized块(注:是所有),注意这里是“类的当前实例”, 类的两个不同实例就没有这种约束了。
  • static synchronized恰好就是要控制类的所有实例的并发访问,static synchronized是限制多线程中该类的所有实例同时访问jvm中该类所对应的代码块。
  • 也就是说synchronized相当于 this.synchronized,而static synchronized相当于Something.synchronized.(后面又讲解)

什么是指令重排序?

  • 为了提高性能,编译器和处理器通常会对指令进行重排序,重排序指从源代码到指令序列的重排序,分为以下三种:
    1. 编译器优化的重排序:在不改变单线程程序语义的前提下重排语句的执行顺序
    2. 指令级并行的重排序:如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序
    3. 内存系统的重排序

as-if-serial和happens-before了解吗?

  • as-if-serial语义是指不论如何重排序,单线程程序的执行结果不能改变。因此,编译器和处理器不会对存在数据依赖关系的操作重排序
  • happens-before是先行发生原则,对有顺序性保障的禁止重排序,保证了正确同步的多线程程序的执行结果不变
  • 两者都是为了不改变程序执行结果的前提下尽可能提高程序执行并行度

什么是原子性、可见性、有序性?

  1. 原子性:基本数据类型的读取和赋值(单个操作)都具备原子性,但是64位长度的long和double需要被划分为两次32位操作,因此不具备原子性。更大范围的原子性,JMM提供了lock和unlock操作,对应到更高层次的字节码指令monitorenter和monitorexit,也就是Java代码中的synchronized
  2. 可见性:指当一个线程修改了共享变量之后,其他线程能够立即得知修改。JMM通过在变量修改后将值同步会主内存,在变量读取前从主内存刷新的方式实现可见性。volatile通过这种方式保证可见性,synchronized则通过unlock前必须先执行sotre和write来保证
  3. 有序性:在本线程内观察所有操作是有序的,在一个线程内观察另一个线程,所有操作都是无序的。前半句指as-if-serial语义,后半句指指令重排序和工作内存与主内存延迟现象。

对volatile有什么理解?

  • volatile的内存语义是:在写一个volatile变量时,把该线程工作内存的值立即刷新到主内存中;在读一个volatile变量时,把该线程的工作内存值置为无效,从主内存中读取
  • 当变量被volatile修饰后,具备两种特性:
    1. 保证变量对所有线程可见
    2. 禁止指令重排序优化:在写操作之前,汇编指令有lock前缀,相当于一个内存屏障,后面的指令不能排在内存屏障之前,同时会先对缓存变量做一次store和write操作,写回主内存中

final可以保证可见性吗?

  • 可以,被final修饰的字段在构造方法中一旦被初始化完成,并且构造方法没有把this引用传递出去(this引用逃逸),在其他线程中就能看见final字段值。

  • JMM禁止把写final域重排序到构造方法之外,在写final之后,构造方法return之前,有一个Store屏障,确保在对象引用被其他线程引用之前,对象的final域已初始化过。

  • 在初次读final域时,JMM要求必须先读这个final域所在的对象引用,通过一个Load屏障,确保初次读对象引用和初次读final域不会被重排序

谈一谈synchronized

  • synchronized关键字解决的是多个线程之间访问同一资源的同步问题,被synchronized修饰的同步代码块可以保证在任意时刻只能有一个线程执行。
  • 可以通过javap看到相关的字节码,在进入和退出同步块时有monitorenter和monitorexit指令,这里的monitor是存在Java对象头中的监视器,对象头中有两部分数据,一是classPointer指向对象的类,二是MarkWord,存储运行时对象的各种信息,包括hashCode、gc年龄、锁状态等。锁状态由锁标志位标识,标志位的指针指向monitor对象。
    • java对象的布局(64位jvm虚拟机)
      • 对象头(所有对象的开头的公共部分,占12字节)
        • Mark Word(64bit,不固定,因有unused)(没有开启指针压缩的话是128bit,默认开启)
          • image.png
        • Class Pointer Address(32bit)(没有开启指针压缩的话是64bit,默认开启)
      • 实例数据(可能有)
      • 对齐数据(可能有)(当整个对象的大小不是8byte的倍数时,需要该对齐数据去填充,因此是不固定大小的,可能为0(即不存在))

moniter对象

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; //指向拥有锁的线程
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}
  • 多个线程进入同步代码块后,首先进入 _EntryList,当线程获取对象的monitor之后,将  _owner 设置为当前线程,同时count++;当线程调用wait方法,将释放当前持有的monitor,  _owner设置为null,count–,同时进入  _WaitSet等待被唤醒;当前线程执行完毕后,也将释放monitor,同时复位count,以便其他线程进入获取monitor

锁类型

在早期版本中,synchronized属于重量级锁,基于操作系统底层的MutexLock互斥锁来实现,线程的挂起和切换需要从用户态到内核态的转换,时间成本高;JDK6对synchronized做了优化,引入了自旋锁、偏向锁、轻量级锁、锁粗化和锁消除等,提高锁的效率,因此锁一共有四个状态,分别是无锁(01-0)、偏向锁(01-1)、轻量级锁(00)、重量级锁(10)

image.png

  1. 自旋锁:如果获取不到锁则会等待一段时间,不直接挂起线程,避免上下文切换的时间消耗。后来又引进了自适应自旋锁,其等待次数和自旋时间是自适应的,更加聪明
  2. 偏向锁:认为每次获取当前锁的都是同一线程,对象头MarkWord中的偏向线程ID存储了这个线程,因此加锁时只需要检查MarkWord中的偏向线程id是否是当前线程,如果是则直接获取锁,否则就要检测当前锁状态是否是偏向锁,如果是则将当前线程id设置到MarkWord中的偏向线程ID,否则通过CAS竞争锁。此时如果有第二个线程竞争锁,会发现偏向线程id已指向了线程1,则出现了锁竞争,会触发重新偏向或升级到轻量级锁,大部分情况下是升级
  3. 轻量级锁:线程1获取锁,如果锁为无锁状态,将MarkWord拷贝到当前线程栈帧中的锁记录LockRecord,JVM将通过CAS尝试将MarkWord中的Lock Word指向LockRecord、将LockRecord的owner指向MarkWord,如果成功则将对象锁状态更新为轻量级锁,否则表示当前轻量级锁已经被获取了,将进入自旋,达到一定次数后膨胀为重量级锁(将MarkWord指向互斥量Mutex)。在释放锁时如果CAS将当前线程的LockRecord替换为MarkWord成功,则释放锁成功,进入无锁状态,否则说明MarkWord已经指向互斥量,锁为重量级锁,需要通知被阻塞的线程
  4. 重量级锁:当线程想要获取锁发现是重量级锁时,将被阻塞,等待锁释放时被唤醒。这样的操作需要操作系统来执行,从用户态转换为内核态,开销大,耗时。

什么是锁消除、锁粗化?

  • 锁消除:JVM判断不存在竞争,则不进行加锁,节省无意义的加解锁。
  • 锁粗化:一般来讲,我们尽量的让同步代码块的范围更小,这样影响的范围会更小,并发程度高,即使存在竞争也不需要等待太长时间,但是如果有一连续的加锁解锁,那么反而会造成性能降低 因此锁粗化就是在这种情况下,一连续的加锁解锁合并成一次加锁解锁,扩展锁的范围

什么是CAS?它会有什么问题?

CAS是指Compare And Swap,比较并交换,有3个操作数,分别是内存位置V、旧的预期值A和准备设置的新值B。CAS指令执行时,当且仅当V的值为A时,处理器才会用B更新V的值。其处理过程是原子操作,不会被其他线程打断。

  1. 从语义上来说,如果V初次读取时A,并且在准备更新时仍为A,这并不能说明它没有被修改过,因为它可能已经先变为C再被改回A,但是CAS是察觉不到的,这个问题称为ABA问题。可以通过引入版本号、时间戳来解决这一问题。
  2. 当多线程并发下,自旋可能一直不成功,循环消耗CPU,解决方法是限制时间/次数;或者采用LongAdder和ConcurrnetHashMap的方式,将锁粒度变小,LongAdder中由volatile修饰base变量和cells数组,当竞争激烈时将线程分担到cells数组中

了解ReentrantLock吗?和synchronized有什么区别

ReentrantLock是JUC Lock接口的实现,synchronized是由JVM实现的,很多优化并不直接暴露给我们,ReentrantLock是API层面实现的,比synchronized拥有更细粒度的控制和更高级的功能,可实现中断响应、限时等待、等待通知、公平锁等。在性能上,JDK6之后做了许多优化,二者性能基本持平

  1. 中断响应:通过lockInterrupt获取锁,能在获取过程中响应中断,如果中断则不再等待获取锁直接返回。而不响应中断的获取锁则是等待到获取锁成功再将线程置于中断状态
  2. 等待通知:通过Condition的await将当前线程放入等待队列,直到获得信号唤醒线程,可以通过多个Condition实现选择性唤醒
  3. 公平锁:按照先来先服务的原则,等待时间长的先获得锁,代码上通过!hasQueuedPredecessors来判断是否有之前已经在等待的线程,再CAS获取锁
  4. 可重入锁:已获取锁的线程可以重复获取

你提到了JUC,还了解JUC包的其他东西吗?

JUC的核心是AQS队列同步器,内部维护一个双向等待队列,内部类Node作为结点包含前置后置指针以及线程标识,它使用一个 volatile int state 变量作为共享资源,当state为1则为独占式,一次只能有一个线程获取锁,否则为共享式;如果线程获取资源失败,则通过addWaiter进入同步队列等待;如果获取成功就执行临界区代码,释放资源时会通知同步队列中的等待线程。通过getState/setState/compareAndSetState来进行状态变更。采用模板方法设计模式,提供了tryAcquire、tryRelease等方法由子类实现。

  1. CountDownLatch(线程计数器):用于阻塞线程,等待所有线程(子任务)完成才继续执行,初始化传入子任务数量,通过countDown方法递减,当任务数量为0时await方法返回,继续执行当前线程;可以限制超时时间

  2. Semaphore:信号量,控制并发线程数,初始化传入并发数,每次通过acquire获取一个/多个资源,执行结束后通过release释放,tryAcquire尝试获取,也可以设置超时

  3. CyclicBarrier(回环栅栏,等待值barrier状态再全部执行):多个线程同步等待,await方法会在准备好的线程达到要求的数量返回,继续执行,在count为0时会将count重置为原先输入的数量开启新一轮等待

  4. FutureTask:可以获取线程执行的返回值,可以取消,可以等待

  5. fork/join:切分子任务并行执行,把子任务放在不同的队列,从队列头部取出任务,执行完自己的任务后去其他子线程的任务队列尾部窃取一个任务。

  6. BlockingQueue:阻塞队列,当队列为空时消费者等待新的可消费对象,当队列满时生产者等待被消费再把可消费对象放入队列,有以下几个分类:

    ① ArrayBlockingQueue:有公平和非公平之分,先进先出,有界

    ② DelayQueue:延迟队列,按执行时间排序,可用于缓存失效和定时任务

    ③ LinkedBlockQueue:两个独立的锁提高并发,无界队列,可不指定大小

    ④ PriorityBlockQueue:compareTo排序实现优先,优先级,可插入null对象

    ⑤ SynchronizedQueue:同步队列,不存储元素,只有元素被消耗才能再次插入,可用于传递数据

创建线程有哪些方式?分别有什么优缺点

  1. 使用new Thread创建线程,可以使用实现了Runable和Callable接口的类,后者可以通过FutureTask获取返回值;继承Thread类,重写run方法。使用这种方式性能差,缺乏统一管理,占用过多资源容易导致oom,并且功能单一
  2. 使用线程池ThreadPoolExecutor:可重用存在的线程减小对象创建消亡的开销,可以控制最大并发线程数避免阻塞,可以定期执行定时执行单线程并发数控制

线程有哪些方法?

  1. sleep方法会让当前线程进入休眠状态,与wait不同的是该方法不会释放锁资源,进入的是TIME_WAITING状态
  2. yield方法会让出CPU时间片,回到RUNNABLE状态,与其他线程一起竞争时间片
  3. join方法用于等待其他线程运行终止,如果当前线程调用了其他线程的join方法,则当前线程进入BLOCKED状态,当另一个线程结束时,当前线程转为RUNNABLE,等待CPU时间片,底层使用的是wait,会释放锁

A、B、C三个线程,分别打印A、B、C,循环打印ABCABC如何实现

  1. AtomicInteger实现:一个实现了Runable接口的ThreadDemo类,设置一个AtomicInteger类型静态变量count为0,一个char数组存放A,B,C,一个char类型的name属性标识是A/B/C线程,在ThreadDemo的run方法中,通过name和count%3得到在char数组下标对应元素是否相等,相等则打印该元素并让count+1
  2. synchronized实现:类似的方式,count使用自定义的类MyInteger,因为改变Integer的值会导致对象引用变化,在进入while循环时使用synchronized锁住静态变量MyInteger,这样就可以保证三个线程同时只有一个能进入,然后再打印时采用相同的方式判断是否相等,相等则进入打印和加1环节,打印完需要通过notifyAll唤醒其他等待锁的线程,不相等则用wait等待被唤醒
  3. Lock实现:lock()和unlock()方法保证同时只有一个线程进入,用相同的方式判断是否打印,在finally中释放锁unlock()

线程有哪些状态?

  1. NEW:新建状态,尚未调用start启动
  2. RUNNABLE:Java将操作系统中的就绪和运行两种状态统称为RUNNABLE,此时线程可能在等待时间片或者正在执行
  3. BLOCKED:阻塞状态,可能由于锁被其他线程占用、调用了sleep/join方法
  4. WAITING:等待状态,不会被分配时间片,需要其他线程通知或终端,可能由于调用了无参的waitjoin方法
  5. TIME_WAITING:限期等待状态,可以在指定时间内自行返回,可能由于调用了带参的waitjoin方法
  6. TERMINATED:终止状态,表示当前线程已执行完毕或异常退出

线程通信的方式有哪些?

命令式编程中线程通信的方式有共享内存和消息传递两种,在共享内存的并发模型中线程间共享内存的公开状态,通过读-写内存中的公共状态进行隐式通信,在消息传递的并发模型中线程间没有公共状态,必须通过发送消息来显式通信。Java并发采用共享内存模型,线程间的通信隐式进行,对程序员完全透明。

  • volatie:读取变量需要从主存中获取,写必须同步刷新回主存,保证所有线程对变量访问的可见性

  • synchronized:确保多个线程在同一时刻只能有一个处于方法/同步块中,保证线程对变量访问的原子性、可见性

  • wait/notify:等待通知机制,线程A调用了对象的wait方法进入等待状态,线程B调用了对象的notify方法,线程A收到通知后结束组测并执行后续操作

  • 管道I/O流:用于线程间数据传输,媒介为内存,生产者消费者模式

  • ThreadLocal:线程共享变量,可以为每个线程创建单独的副本,副本值是线程私有的,互相之间互不影响

线程池

创建线程池有哪些参数,线程池有哪些分类?

  1. corePoolSize:核心线程数
  2. maximumPoolSize:最大线程数
  3. keepAliveTime:线程没有任务执行时最大存活时间
  4. unit:时间单位
  5. workQueue:任务队列,存放被提交但是尚未执行的任务
  6. threadFactory:线程工厂,用来创建线程
  7. rejectHandler:拒绝策略,默认抛异常,可设为丢弃任务,丢弃最老任务,让提交线程执行该任务(造成提交线程无法继续提交其他任务的问题)
  • 注意:
    • 小于核心线程数量:直接创建新线程处理任务。
    • 在核心和最大之间:只有当workingQueue满才创建新线程。
    • 当线程数到达max时,如果阻塞队列未满则放入阻塞队列等待空闲线程处理,否则,根据线程池设置的拒绝策略处理(抛异常等)
  • 分类
  1. newFixThreadPool:核心线程和最大线程相同,指定核心线程和最大线程数,采用无界队列,当无空闲线程将任务放到无界队列等待被处理(可能造成oom)。适用于CPU密集型,确保CPU在长期被工作线程占用时,尽可能减少分配线程,适用执行长期任务
  2. newCachedThreadPool:核心线程为0,最大线程为整型最大值,使用同步队列,因为没有核心线程所以任务直接放在队列中,如果有空闲线程则取出任务执行,否则创建新线程执行任务。keepAliveTime为60s。适用于并发执行量大短期的小任务
  3. newSingleThreadPool:核心线程和最大线程都为1,使用无界队列,串行执行任务,keepAliveTime为0,通常是一个线程不停的串行执行所有任务
  4. newScheduleThreadPool:核心可选,最大线程数是整型最大值,keepAliveTime为0,使用延迟队列,线程从延迟队列中取出执行时间大于等于当前时间的任务执行完成后修改该任务的时间为当前时间并放回队列。适用于周期/定期执行的任务
  • 有两种提交任务的方式
    1. execute()
    2. submit()
    3. 两者区别
      • execute只能提交Runnable类型的任务,无返回值。submit既可以提交Runnable类型的任务,也可以提交Callable类型的任务,会有一个类型为Future的返回值,但当任务类型为Runnable时,返回值为null。
      • execute在执行任务时,如果遇到异常会直接抛出,而submit不会直接抛出,只有在使用Future的get方法获取返回值时,才会抛出异常。

线程池有哪些状态?

  • RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。线程池的初始化状态是RUNNING。线程池被一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0。

  • SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。调用线程池的shutdown()方法时,线程池由RUNNING -> SHUTDOWN。

  • STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。调用线程池的shutdownNow()方法时,线程池由(RUNNING or SHUTDOWN ) -> STOP。

  • TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为 TIDYING 状态时,会执行钩子方法 terminated()。因为terminated()在ThreadPoolExecutor类中是空的,所以用户想在线程池变为TIDYING时进行相应的处理;可以通过重载terminated()函数来实现。

    • 当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。

    • 当线程池在STOP状态下,线程池中执行的任务为空时,就会由STOP -> TIDYING。

  • TERMINATED:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。

拒绝策略

RejectedExecutionHandler类型

  1. AbortPolicy:线程池默认的策略,如果元素添加到线程池失败,会抛出RejectedExecutionException异常

  2. DiscardPolicy:如果添加失败,则放弃,并且不会抛出任何异常

  3. DiscardOldestPolicy:如果添加到线程池失败,会将队列中最早添加的元素移除,再尝试添加,如果失败则按该策略不断重试

  4. CallerRunsPolicy:如果添加失败,那么主线程会自己调用执行器中的execute方法来执行改任务

  5. 自定义:如果觉得以上的策略都不合适,那么可以自定义符合场景的拒绝策略。需要实现RejectedExecutionHandler接口,并将自己的逻辑写在rejectedExecution方法内

谈一谈ThreadLocal

ThreadLocal是线程共享变量,主要用于一个线程内跨类、方法传递数据,ThreadLocal有一个静态内部类ThreadLocalMap,其key是ThreadLocal对象,值是Entry对象,Entry内只有一个Object的value,ThreadLocal是线程共享的,但ThreadLocalMap是线程私有的,ThreadLocal主要有get、set、remove三个方法

  • set:首先获取当前线程,然后再获取当前线程对应的ThreadLocalMap类型的对象map,如果map存在就设置key,key是当前的ThreadLocal对象,value是传入的参数;否则通过createMap方法创建一个ThreadLocalMap,再设置值
  • get:首先获取当前线程,然后再获取当前线程对应的ThreadLocalMap类型的对象map,如果map存在就以当前ThreadLocal对象作为key获取Entry类型的对象e,如果e存在就返回它的value;如果map或者e不存在,就调用setInitialValue方法先为当前线程创建一个ThreadLocalMap对象然后返回默认的初始值null
  • remove:获取ThreadLocalMap类型的对象map,如果map不为空,则解除ThreadLocal这个key及其value的联系
  • 存在的问题
    1. 脏数据:线程池会复用Thread,因此ThreadLocal也会被复用,而如果没有通过remove方法清理与线程相关的ThreadLocal数据,则下一个线程可能会get到之前的数据
    2. 内存泄漏:由于ThreadLocal是弱引用,但Entry的value是强引用,因此当ThreadLocal被回收后,value没有被释放,造成内存泄漏,因此需要及时调用remove清理

I/O流问题

同步、异步、阻塞、非阻塞都是什么概念?

  • 同步和异步是通信机制,阻塞和非阻塞是调用状态
    • IO(read、write系统调用)分为两步骤
      1. 发起IO请求
      2. 实际的IO读写(内核与用户态的数据拷贝)
    • 阻塞IO和非阻塞IO的区别在于第一步,发起IO请求的进程是否会被阻塞,如过阻塞知道IO操作完成才返回那么就是传统的阻塞IO,如果不阻塞,那么就是非阻塞IO。
    • 阻塞IO是IO操作需要彻底完成后才能返回用户空间;
    • 非阻塞IO是IO操作调用后立即返回一个状态值,无需等IO操作彻底完成。
    • 在编程上非阻塞IO一般采用IO状态事件+回调方法的方式来处理IO操作
      • 如果是同步IO,则状态事件是读写就绪,此时数据仍在内核态,但是已经准备就绪,可以进行IO读写操作
      • 如果是异步IO,则状态事件是读写完成,此时数据已经在应用进程的地址空间(用户态)中
    • 同步IO和异步IO的区别在于第二步实际的IO读写(内核态与用户态的数据拷贝)是否需要进程参与,如果需要进程参与则是同步IO,如果不需要进程参与就是异步IO
    • 同步IO是用户线程发起IO请求后需要等待或轮询内核IO操作完成后才能继续执行;
    • 异步IO是用户线程发起IO请求后可以继续执行,当内核IO操作完成后会通知用户线程,或调用用户线程注册的回调函数。
    • 如果实际的IO读写需要请求进程参与,那么就是同步IO。因此阻塞IO、非阻塞IO、IO复用、信号驱动IO都是同步IO
    • 额外小知识
      • 另外在编程的方法调用上也存在同步调用和异步调用的说法。就拿RPC来说吧:
        • 如果同步调用,则调用的结果会在本次调用后返回。
        • 如果异步调用,则调用的结果不会直接返回。会返回一个Future或者Promise对象来供调用方主动/被动的获取本次调用的结果。

Java中有哪些IO模型,简单介绍下

  1. BIO:同步阻塞式IO,JDK1.4之前的IO模型,服务器实现模式为一个连接请求对应一个线程,也就是一个客户端请求需要创建一个线程,适用于连接数目少且服务器资源多的情景。
  2. NIO:同步非阻塞IO,JDK1.4引入,服务器实现模式为多个连接请求对应一个线程,客户端连接请求会注册到一个多路复用器Selector,Selector轮询到连接有IO请求时才启动一个线程处理,适用于连接数目多且连接时间短的场景。
  3. AIO:异步非阻塞IO,JDK7引入,服务器实现模式为一个有效请求对应一个线程,客户端的IO请求都是由操作系统先完成IO操作再通知服务器应用来直接使用准备好的数据,适用于连接数目多且连接时间长的场景。

详细介绍下NIO

不同于传统的IO,NIO以块的方式传输数据,一次处理一个数据块,可以控制读取某个位置的数据,速度更快。它有几个重要的组件:

  1. Buffer:缓冲区,本质是一块可读写的内存,用来简化数据读写,不再以字节来处理数据,并提供flip、clear、compact来切换读写状态,通过读写位置position和极限位置limit来实现
  2. Channel:通道是对IO中流的模拟,通过Channel读写数据,将数据读取到Buffer/通过Buffer将数据写出,一个Buffer可以切换读写,被不同的Channel使用,这让NIO成为了非阻塞的IO,通过Channel的返回就可以知道缓冲区是否有数据,而不是和IO流一样需要阻塞等待数据。
  3. Selector:多路复用器,轮询检查多个Channel的状态,判断注册事件是否发生,即判断Channel是否处于可读/可写状态

其工作流程是通过Selector的静态方法open实例化一个选择器,通过Channel的register方法将通道的某个事件和Selector绑定起来,事件有CONNECT、ACCEPT、READ、WRITE。Selector的select方法返回目前就绪的通道数,通过selectKeys得到对应的集合,处理其中的事件

零拷贝是什么?Java中如何实现?

传统的IO在进行数据传输时,需要经历四次COPY,内核与I/O设备之间的两次COPY通过DMA直接内存拷贝,用户程序和内核之间的两次COPY需要经历用户态和内核态的切换零拷贝避免了用户态和内核态的copy,减少了两次用户态和内核态的切换。

Java中nio包下的FileChannel的transferTo写出和transferFrom读取可以实现零拷贝,直接将bytes数组从调用它的channel传输到另一个channel,不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标channel的内核态,提高了效率。底层是通过系统的sendfile实现。

IO包下有哪些流?

主要分为字符流和字节流,字符流一般用于处理文本文件,其他类型用字节流处理

  • 字节流:使用了装饰器模式,InputStream和OutputStream是抽象组件,FileInputStream、ByteArrayInputStream是基础实现类,FilterInputStream是抽象装饰器,其实现类BufferInputStream、PushBackInputStream等为基础实现类提供了额外的功能,使用BufferInputStream装饰FileInputStream(在FileInputStream外套上BufferInputStream)可以实现具有缓存的输入流;使用PushBackInputStream装饰ByteArrayInputStream可以实现具有回退读取unread的功能的输入流;DataInputStream提供了读取Java基本数据类型的方法,实现上并未改变基本的读写方法,只是动态的处理成需要的类型。
  • 字符流:在程序中一般操作的是字符形式的数据,Java提供了基本的InputStreamReader和OutputStreamWriter完成了字节流和字符流之间的编解码转换,家族结构和Stream类似也采用了装饰器模式

序列化和反序列化是什么?

Java对象在JVM退出时会全部销毁,如果需要将对象及状态持久化,就要通过序列化实现,将内存中的对象保存在二进制流中,需要时再将二进制流反序列化为对象。对象序列化保存的是对象的状态,因此类的静态变量不会被序列化。常见的序列化方式有以下几种:

  1. Java原生:实现Serializable接口,Java 序列化保留了对象类的元数据(如类、成员变量、继承类信息)以及对象数据,兼容性最好,但不支持跨语言,性能一般。序列化和反序列化必须保证序列化ID一致,如果没有序列化ID,则会抛异常。具体的序列化过程:判断对象类型,写入对象类型标记;写入对象类信息,包括类名、序列化ID、字段数、字段名等;写入实例数据,基本类型直接写入,引用类型递归调用writeObject写入
  2. Hessian:支持跨语言、动态类型,用一个字节表示常用基础类型,极大缩短二进制流,更加高效
  3. JSON:转化为JSON字符串,不记录类型信息,因此反序列化需要提供类型信息才能正确进行;可读性高

JVM

介绍一下Java虚拟机的整体结构?各个模块的作用

在java程序的执行过程中,jvm把它管理的内存划分为不同的数据区,其中线程私有的有程序计数器、虚拟机栈、本地方法栈,线程共享的有堆、方法区,虚拟机的类加载器负责吧类信息加载到内存中,执行引擎负责执行字节码,采用即时编译技术

image.png

  1. 程序计数器:每个线程有自己的计数器,指示当前线程所执行的字节码的行号,改变计数器的值选取下一条指令。对于java方法记录了字节码的指令地址;对于本地方法,值为undefined
  2. 虚拟机栈:每个栈帧中有局部变量表存储方法内使用的变量、操作数栈、动态链接、返回地址,每新调用一个方法就创建一个栈帧,因此递归过深超过虚拟机允许的深度会报StackOverflowError
  3. 本地方法栈:native方法的栈,调用本地方法虚拟机栈不变,动态链接直接调用指定本地方法,同样会报StackOverflowError
  4. 方法区:存储类信息、常量、静态变量、即时编译的代码缓存等。jdk8前用永久代实现方法区,容易内存溢出,jdk7把永久代的字符串常量池、静态变量等移出,jdk8废弃永久代,采用本地内存实现的元空间替代。

运行时常量池、字符串常量池、Class常量池

  • Class常量池:class文件中包含了类的版本、字段、方法、接口等信息,还有常量池(Constant Pool Table),存放编译器生成的字面量和符号引用
  • 运行时常量池:类会经过加载、验证、准备、解析、初始化加载到内存,jvm会将class常量池中的内容存放在运行时常量池,class常量池存放的并不是对象实例,因此在解析阶段将会查找字符串常量池,将符号引用转换为直接引用
  • 字符串常量池:在经过验证、准备阶段后,在堆中生成字符串实例,将其引用值存放到字符串常量池,被所有类共享

java程序是怎么运行的

首先通过javac将.java文件编译为jvm可加载的.class字节码文件,字后用过即使时编译器JIT把字节码文件翻译为本地机器码,之后执行引擎将执行执行代码

有哪些类加载器?类加载的过程是怎么样的?

  • 启动类加载器BootstrapClassLoader负责加载/lib/rt/下的核心类库java.;由c++编写,平台/拓展类加载器ExtClassLoader加载/lib/ext/下的拓展类库javax.;应用加载器AppClassLoader加载用户写的类,位置在classPath下;另外,还可以通过继承ClassLoader实现自定义的类加载器,通过findClass方法将字节数组传入defineClass方法负责加载到jVM中
  • 类加载采用双亲委派机制:一个类加载器接受到类加载请求会将请求委派给父加载器,只有当父加载器无法完成时,才会由子加载器尝试加载。可以确保类在每个类加载器环境都是同一个,避免重复加载
  • 类加载过程分为加载、链接、初始化三个阶段,加载阶段将class字节码加载到jvm,生成class对象,链接阶段先通过验证检查class的安全性格正确性,之后为类变量分配空间并且设置初始值,称为为准备,在解析时将常量池内的符号引用转换为直接引用,最后的初始化阶段执行类变量赋值和静态代码块。

符号引用和直接引用

  • 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在java虚拟机规范的Class文件格式中。
  • 直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄如果有了直接引用,那引用的目标必定在内存中已经存在了

创建对象new的时候发生了什么

  1. 当jvm遇到字节码new指令时,首先检查该指令的参数能否在常量池定位到一个类的符号引用,并检查引用代表的类是否已被加载、解析、初始化。
  2. 类加载检查通过后,虚拟机为新生对象分配内存
  3. 内存分配完成后虚拟机将成员变量设为初始值,保证对象的实例字段可以不赋初值就使用
  4. 设置对象头,包括hashcode、GC信息、锁信息、类类型等
  5. 执行init方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋给引用变量

如何判断对象是否需要被回收?

  • 引用计数:在对象中添加一个引用计数器,如果被引用计数器加1,引用失效时计数器减1,如果计算器为0则被标记为垃圾。原理简单,效率高,但是存在对象循环引用的问题,导致计数器无法清零
  • 可达性分析:判断对象的引用链是否可达。将所有对象组成图,从一系列的GC Root对象开始向下搜索其他对象,路径称为引用链,如果所有的GC Root都无法到达的对象则判定为垃圾。可以作为GC Root对象的所有虚拟机栈和本地方法中引用的对象、类静态属性引用的对象、常量引用的对象。注意:不可达对象不等价于可回收的对象,不可达对象至少要经过两次标记过程才变为可回收对象,两次标记仍然是可回收对象则面临被回收。

java有哪些引用类型

  1. 强引用:最常见的引用,只要对象强引用且GC Root可达,在垃圾回收是即使内存耗尽也不会被回收
  2. 软引用:在内存即将耗尽时,会把软引用关联的对象加入回收范围
  3. 虚引用: 只能生存到下次年轻代GC前
  4. 虚引用:定义完成后无法通过引用来获取该对象,只是为了在对象回收时收到一个系统通知

有哪些GC算法

  • 标记清除算法(Mark-Sweep):最基础的垃圾回收算法,分为两阶段,标记和清除。标记阶段记出所有需要回收的对象,清除阶段回收被标记的对象所占用的空间,如图,该算法的缺点是内存碎片化严重,后续可能发生大对象找不到可利用的空间

image.png

  • 复制算法(copying):为了解决Mark-Sweep算法内存碎片化的缺陷被提出的算法,按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。如图,该算法简单,内存效率高不易产生碎片,但是可用内存被压缩为原来的一半,且存活的对象增多的话,copying算法效率大大降低

image.png

  • 标记整理算法(Mark-Compact):为解决以上两个算法的缺陷提出的,标记阶段和Mark-Sweep一样,标记后不是清理,而是将存活对象移向内存一端,然后清除端边界外的对象

image.png

常见的垃圾收集器有哪些?

image.png

  • Serial:单线程收集,采用复制算法,会STW(停止所用工作线程),Client模式新生代默认收集器
  • ParNew:多线程收集,其他和Serial相同,追求低停顿,是很多jvm虚拟机运行在Server模式下新生代默认收集器
  • Parallel Scavenge:多线程收集,采用复制算法,也是更关注系统的吞吐量,其自适应调整策略是与ParNew一大区别。
  • Serial Old:单线程收集,采用标记-整理算法,Client模式下老年代默认收集器
  • Parallel Old:多线程收集,采用标记-整理算法,追求吞吐量
  • CMS:追求最短停顿时间,采用标记-清除算法,过程复杂
  • G1:不再区分以老年代/新生代简单内存分配,以Region内存布局面向局部收集,实现了可预测的停顿

CMS回收过程

image.png

  • 初始标记阶段进入STW,使用单线程标记GC Root直接关联的对象
  • 并发标记阶段从初始标记的对象开始遍历,耗时较长但是不需要停顿
  • 重新标记阶段修正在并发标记期间因用户程序运作而产生变动的部分记录,需要STW
  • 并发清除阶段清理垃圾,不需要移动对象,可以并发执行

G1过程

image.png

  • 初始标记阶段进入STW,标记GC Root直接关联的对象,让下一阶段用户线程并发运行时能正确地在可用的Region中分配对象
  • 并发标记阶段从初始标记的对象开始遍历,查找整个堆吗,扫描完成要重新处理过程中变化的部分
  • 最终标记进入短暂的STW,处理并发阶段结束后仍遗留的少量SATB记录
  • 筛选回收对个Region的回收价值排序,根据用户期望停顿事件制定回收计划,需要暂停用户线程,多线程并发完成

CMS和G1的使用场景

  • CMS追求最短停顿时间,而GC是停顿时间将随着内存堆内存的增大而增大,因此在大内存中需要选择使用CMS,官方建议8G。同时,在堆内存较小时,容易造成频繁的CMS GC,而在并发清理的过程中会抢占应用的CPU,CMS标记采用标记-清除法,会产生很多内存碎片
  • 在需要可控可预期的停顿周期场景下,使用G1更加合适,并且G1采用标记-整理算法,也适用在应用汇产生大量内存碎片的场景下

jvm哪些区域会发生oom,哪些不会

会oom

  • 堆内存:当堆中没有内存可以分配给对象实例并且无法再拓展时,就会抛出oom的错误,并会指明是head space,可通过-Xmx和-Xms来控制堆内存的大小,发生堆上oom的可能是内存泄露,也可能是堆分配不合理
  • 虚拟机栈和本地方法栈:如果线程请求的栈大于所分配的栈大小,就会抛出StackOverFlowError的错误,比如执行一个递归过深的方法。如果虚拟机栈是可拓展的,当拓展到无法申请足够的内存,会抛出一个oom的错误
  • 直接内存:NIO可以使用native函数库在堆外内存上直接分配内存,当直接内存不足时,会导致oom
  • 方法区:元空间内存不足也会抛出MetaSpace的oom

不会oom

  • 程序计数器中不会发生oom

你知道哪些内存分配和回收策略

  • 对象优先在Eden区分配:大多数情况下对象在新生代Eden区分配,当Eden不足时会触发Minor GC
  • 大对象直接进入老年代:通过指定-XX:PretenureSizeThresold参数,大于该值就直接在老年代分配
  • 长期存活对象进入老年代:通过指定-XX:MaxTenuringThreshold参数,对象年龄大于该值的进入老年代,每经过一次Minor GC,年龄+1
  • 动态对象年龄判定:当Survivor中相同年龄所有对象大小的总和大于Survivor的一半,则将年龄不小于该年龄的对象放入老年代
  • 空间分配担保:Minor GC前虚拟机必须检查老年代最大连续可用空间是否大于新生代对象的总空间,满足则说明Minor Gc安全;如果不满足,虚拟机会查看-XX:HandlePromotionFailture是否允许担保失败,允许则检查老年代最大连续可用空间是否大于历次晋升老年代对象的平均大小,如果满足则冒险尝试Minor GC,否则进行Full GC

jvm调优参数知道吗?

分配内存相关

  • -Xms256m:初始化堆大小为256m
  • -Xmx2g:堆最大内存为2g
  • -Xmn50m:新生代的大小为50m

日志相关

  • -XX:+PrintGCDetails 打印gc的详细信息
  • -XX:+HeapDumpOnOutOfMemoryError 在发生OutOfMemoryError错误时,生成dump快照

新生代和老年代内存分配比例

  • -XX:NewRatio=4 设置年轻代和老年代的内存比例为1:4
  • -XX:urvivorRatio=8 设置新生代Eden和Survivor比例为8:2

选择垃圾回收器

参数上都是新生代的垃圾回收器

  • -XX:+UseSerialGC 新生代和老年代都用串行的垃圾收集器Serial+Serial Old
  • -XX:+UseParNewGC 指定使用ParNew+Serial Old垃圾回收器组合
  • -XX:+UseParallelGC 新生代使用Parallel Scavenge,老年代使用Serial Old 参数上都是老年代的垃圾回收器
  • -XX:+UseParallelOldGC 新生代使用Parallel Scavenge老年代使用ParallelOldGC
  • -XX:+UseConcMarkSweepGC 新生代使用ParNew老年代使用CMS

分配一些内存

  • -XX:NewSize 新生代最小值
  • -XX:MaxNewSize 新生代最大值
  • -XX:MetaspaceSize 元空间初始化大小
  • -XX:MaxMetaspaceSize 元空间最大值

了解哪些jvm调优工具

  • JVisualVM:自带,不需要配置,可用看到内存消息、线程消息、dump和CPU分析
  • JPS: 查看指定host的JVM进程、PID、启动路径和参数等等
  • JConsole:监控某个java应用程序,在overview中查看内存、线程、类及CPU使用情况
  • Jprofiler:分析dump文件,可用于查看内存情况

常见的设计模式

讲讲单例模式

单例模式属于创建型模式,一个单例类在任何情况下只存在一个实例,私有化构造方法,提供一个静态方法向外提供静态实例变量。数据库连接池、ServletContext、Spring的单例bean等都是单例模式的应用

  • 饿汉式:线程安全,在加载时完成实例的创建,提供静态方法获取实例变量
public class HungrySingleton {

    private HungrySingleton(){

    }

    private static HungrySingleton instance = new HungrySingleton();

    public static HungrySingleton getInstance(){
        return instance;
    }
}
  • 懒汉式:在外部调用时才会创建实例,通过双重检测锁、静态内部类、枚举等方法来保证线程安全
    • 双重检测锁:synchronized加锁,volatile禁止指令重排
    • 静态内部类:线程安全,延迟加载
    • 枚举:简洁、序列化和反序列化都无法破解。反射的newInstance方法会判断是否被枚举修饰,是则会抛异常。序列化时枚举的每个变量都是静态的,在jvm中之后一份
//线程不安全
public class LazySingleton {
    
    private LazySingleton(){
        
    }
    
    private static LazySingleton instance;
    
    public static LazySingleton getInstance(){
        if(instance==null)
            instance=new LazySingleton();
        return instance;
    }
}
//双重检测锁DCL
public class LazySingletonTwoCheckLock {

    static Object object=new Object();

    private LazySingletonTwoCheckLock(){

    }

    //禁止指令重排序
    private volatile static LazySingletonTwoCheckLock instance=null;

    public static LazySingletonTwoCheckLock getInstance(){
        if(instance==null){
            synchronized (object){
                if(instance==null)
                    instance=new LazySingletonTwoCheckLock();
            }
        }
        return instance;
    }
}
//静态内部类
public class LazySingletonInnerClass {

    private LazySingletonInnerClass(){}

    public static LazySingletonInnerClass getInstance(){
        return InnerClass.instance;
    }

    private static class InnerClass{
        public static final LazySingletonInnerClass instance = new LazySingletonInnerClass();
    }
}
//枚举实现
public enum LazySingletonEnum {
    INSTANCE;
}

讲讲工厂模式

工厂模式属于创建型模式,有简单工厂模式、工厂方法模式、抽象工厂模式

  1. 简单工厂模式:由工厂对象创建实例,客户端只需要传入参数即可,由工厂对象进行判断和创建具体类型的实例。适用于创建对象类型较少的场景,如果要增加新类型,需要修改工厂对象的判断逻辑,违背了开闭原则。Calendar抽象类的getInstance方法调用了createCalendar根据不同的地区参数创建不同的日历对象,Spring的BeanFactory通过Bean的唯一标识来获取Bean
  2. 工厂方法模式:定义一个创建对象的接口,让接口的实现类决定创建哪种对象,让类的实例化推迟到子类中进行,解决了简单工厂中产品拓展的问题。Collection接口中定义了一个iterator工厂方法,返回了一个Iterator类的抽象产品,该方法由ArrayList、HashMap等具体工厂实现;Spring的FactoryBean接口的getObject方法也是工厂方法
  3. 抽象工厂模式:提供一个创建一系列相关或相互依赖对象的接口,无需指定他们的具体类,主要用系统的产品有多余一个的产品族,而系统只消费其中某一个产品族产品的情况。Connection接口应用了抽象工厂,其中包括StateMent、Blob、Savepoint等抽象产品

建造者模式了解吗

建造者模式属于创建型模式,一个类由多个部件构成,一个部件可以有多种实现,通过组合可以创建出不同的实例,在创建该类时,使用者通过一个指挥者实例,实例中有一个抽象建造者,抽象建造者定义了待实现的建造方法(针对不同部件),抽象建造者的实现类则做出了具体实现。指挥者通过建造者可以完成对产品的建造,最后将结果返回给使用者。

讲一讲代理模式

代理模式属于结构型模式,为其他对象提供一种代理以控制对被代理对象的访问,可以增强目标对象的功能,降低代码耦合度,扩展性好。分为静态代理和动态代理两种方式,静态代理在运行前就已经确定代理类和被代理类的关系,动态代理具有更强的实适用性

  • JDK动态代理 被代理类需要实现含有业务方法的接口,代理类需要实现InvocationHandler invoke方法来调用具体的业务方法,最终通过Proxy.newInstance生成代理类,会调用ProxyGenerator的generate方法来生成字节码,再用类加载器来装载生成的代理类。
    • 在Spring AOP中,生成的Proxy类拥有真实类实现的接口的所有方法,其实就是通过实现相同的接口,然后在对应的方法周围加入切面逻辑。从Proxy的字节码可以看到,声明了私有静态成员Method0123...通过静态代码块利用反射来初始化各个方法。通过Proxy类的成员变量InvocationHandler的invoke方法调用具体方法。invoke中在调用具体方法前后会有切入的逻辑
import aop.UserService;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
 
public final class $Proxy11 extends Proxy implements UserService {
    private static Method m1;
    private static Method m4;
    private static Method m3;
    private static Method m2;
    private static Method m0;
 
    public $Proxy11(InvocationHandler var1) throws  {
        super(var1);
    }
 
    public final boolean equals(Object var1) throws  {
        try {
            return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }
 
    public final void A() throws  {
        try {
            super.h.invoke(this, m4, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
        
    }
 
    public final void B() throws  {
        try {
            super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    public final int hashCode() throws  {
        try {
            return (Integer)super.h.invoke(this, m0, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }
 
    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
            m4 = Class.forName("aop.UserService").getMethod("A");
            m3 = Class.forName("aop.UserService").getMethod("B");
            m2 = Class.forName("java.lang.Object").getMethod("toString");
            m0 = Class.forName("java.lang.Object").getMethod("hashCode");
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }

  • CGLIB动态代理 以继承的方式动态生成目标类的代理,通过修改字节码(asm框架)的方式织入,不需要接口,因为cglib是利用继承真实类通过super来调用真实类方法,并加入新逻辑的方式来实现的

讲一讲装饰者模式

装饰者模式属于结构型模式,不改变类原有的属性,动态的增加其他属性,装饰器是一个抽象指定了待实现的装饰方法,可以有不同的实现,将已有的类传入装饰器后对其进行装饰。Java io流家族应用了该模式

装饰器和代理有啥区别

装饰器模式的关注点在于给对象动态添加方法,而动态代理更注重对象的访问控制。动态代理通常会在代理类中创建被代理对象的实例,而装饰者模式会被装饰者作为构造方法的参数

适配器模式了解吗?有什么应用?

适配器模式属于结构型模式,将一个类的接口转换为使用者接受的另一个接口,解决由于不兼容而不能一起工作的问题。适配器Adapter继承/实现期待的类/接口,在实现期待的接口时,使用用于适配的不兼容者来实现。这样外部看来就是期待的状态但我们内部通过适配器将原本不兼容的转化为可接受的状态。Java的字符流操作应用了适配器模式,SpringMVC的HandlerAdapter通过适配规则调用不同的处理请求的handle方法,Array.asList将数组转换为对应的集合

模板方法模式

模板方法模式属于行为模式,使子类可以在不改变算法结构的情况下重新定义算法的某些步骤,适用于抽取子类重复代码到公共父类。JUC的AQS应用了模板方法模式,HttpServlet定义了一套处理http请求的模板,service为模板方法定义了基本处理流程,doGet/doPost为基本方法,子类可重写这些方法

讲讲责任链模式

责任链模式属于行为型模式,责任链是指由多个继承了抽象处理类的子类组成的处理链条,通过next连接,将对于某个请求的处理交给一条处理链,子类会不断地转发给下一个子类一直向下处理。Spring的拦截器中HandlerExecutionChain将拦截器放在一个list中,前置胡处理做正向循环直到被拦截就停下,后置处理从这个位置开始逆向循环做后置处理

对观察者模式有什么了解

观察者模式属于行为型模式,也叫发布订阅模式,定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都能得到通知被自动更新。ServletContextListener能够监听ServletContext对象的生命周期,当启动时调用contextInitialized方法,终止时调用contextDestroyed方法。 如何实现:一个包含观察者集合和对观察者集合进行操作以及通知观察者的方法的抽象类。一个包含了更新自己的抽象方法(在收到被观察者状态改变通知时被调用)的抽象类。被观察者实现类实现具体的通知方法,当实现类状态改变时通过通知方法调用观察者更新自己的方法。