Java面试重点
基础
Object类方法 4
getClass() 获取运行时对象的Class对象
hashCode() 获取对象的哈希码,在哈希表中被使用
toString() 输出 类名@实例的十六进制哈希码
clone() 返回当前对象的一份拷贝
equals() 比较对象是否等价
BIO、NIO、AIO 3
是 Java 对各种 IO 模型的封装,Java 中的 IO 都是依赖操作系统内核进行的,其实调用的是内核中的read 和 write 两大系统调用
IO交互的步骤
- 网卡接收网络传输过来的数据,并将数据写入内存
- 写入完成后,发送一个中断信号给 CPU,操作系统便得知有数据到来,然后通过网卡中断去处理数据
- 将内存中的数据写到对应的 socket 接收缓冲区中
- 接收缓冲区数据写好后,应用程序开始进行数据处理
同步和异步
同步:两个任务相互依赖,一个任务要依赖另一个任务的执行
异步:两个任务完全独立,一方的执行不需要等待另一方执行完成
阻塞和非阻塞
阻塞:发起一个请求,一直在等结果返回,期间会被挂起,不执行后续操作
非阻塞:在等待结果返回时,先去做其他工作,隔段时间查看同步是否完成
BIO Blocking I/O
是传统的 java.io 包,交互的方式是同步阻塞,就是在发起一个读写请求后,在读写操作完成之前时,线程会一直阻塞,直到读写操作完成才会继续执行。代码简单直观但是效率低。适用于连接数目小且固定的结构。在网络通信中使用 BIO,如果想要同时处理多个客户端请求,就需要创建多个线程。
NIO Non-Blocking IO
是 Java 1.4 以后引入的 java.nio 包,提供Channel,Selector,Buffer等抽象。它支持面向缓冲的,基于通道的 IO 操作方法
线程发起 IO 请求后,立即返回去做其他工作,定期检查 IO 缓冲区数据是否就绪。 等到读写完成之后线程再继续处理数据。Java 中的 NIO 加入多路复用技术,会轮询一堆 IO 缓冲区中有哪些准备就绪。
IO 面向流,直接将数据写到 Stream 中;NIO 面向缓冲区 Buffer,直接将数据读读到 Buffer 中进行操作,任何时候访问 NIO 中的数据,都是通过缓冲区进行操作
NIO 通过 Channel 进行读写,通道是双向的,可读也可写,通道只能和 Buffer 进行交互;IO 的流是单向的,分输入流和输出流
NIO 使用 Selector 来基于单线程处理多个通道
AIO
Java 7 中引入了 NIO 2,是异步非阻塞 IO。线程在执行 IO 操作后,马上返回继续执行操作,等到缓冲区就绪,由内核通知线程或者执行回调函数来进行后续操作。
反射 2
反射是指在运行时,对任意一个类都能知道这个类的所有属性和方法。对于任何一个对象,都能调用它的任意一个方法和属性。这种动态获取信息和动态调用对象方法的功能称为反射机制。
原理:反射是由Class类对象开始的。当一个类被加载以后,JVM就会自动产生一个Class对象,通过Class对象我们就能获得加载到虚拟机中的这个类的方法和属性。
获取类的方法有
- Class.forName() 底层通过类加载器获取 Class 对象
- 类名.class
- 对象.getClass()
在日常开发中反射最终目的主要两个:
- 创建实例
- clazz.newInstance() 底层调用类拥有的 Constructor 对象的无参构造方法,所以需要保证对应的类有无参构造方法
- 调用 java.lang.reflect.Constructor 类的 newInstance 方法,可以调用有参构造函数,例如 clazz.getConstructor(String.class).newInstance(“xx”);
- 反射调用方法
- 通过 clazz.getMethod(name, ParameterType),根据方法名和参数类型匹配唯一的 Method 对象返回
- 通过 method.invoke(o, ) 来执行方法
使用场景:框架开发的动态配置
在properties里写好了配置,在Java类里面解析配置内容,得到对应实体类的字节码字符串,使用反射机制获取到某个类的Class实例,然后动态配置实例的属性。
==、equals()区别 2
对于基本数据类型,==比较他们的值是否相等,基本数据类型没有equals()方法
对于引用数据类型,==比较他们是否引用同一对象,而equals()判断引用的对象是否等价,如果equals没有被重写,就比较指向的地址是否相等,如果被重写,会比较地址中的内容,例如String中比较的是字符是否都相同。
判断方法是:
- 如果是同一对象的引用,返回true
- 检查是否是同一类型,如果不是,返回false
- 将Object对象转型然后判断关键域是否相等
深拷贝和浅拷贝 2
浅拷贝是按位拷贝,会创建一个新的对象,拥有原始对象属性值的拷贝。对于基本数据类型,拷贝的就是他们的值,但是对于引用数据类型,只是拷贝了引用地址,与原对象相比,在内存中指向的还是同一个对象
深拷贝相比浅拷贝,还把其中引用其他对象的变量指向了一个被复制过的新对象。也就是把拷贝对象所引用的对象也复制了一遍。所以深拷贝很慢。
Java 的三大特性
封装性
就是把相关属性和方法都封装到一个类中,保留特定的借口供外界调用
继承性
可以从一个已知的类中派生出一个新的类,具有父类的一些方法和属性,也可以通过重写父类方法来扩展新的功能
多态性
本质就是一个程序存在同名的多个不同方法,主要通过:
- 子类重写父类方法
- 类中对方法进行重载
- 将子类对象作为父类对象来使用
static、final关键字
static
内部类就是定义在类内部的类,分为非静态内部类和静态内部类,其中只有内部类才可以被声明为静态。
非静态内部类
- 依赖于外部的实例创建,外部类要先创建实例,再通过实例创建内部类
- 内部成员不能是静态的
- 可以访问外部类的所有属性
静态内部类
- 可以直接通过外部类访问创建。内部成员可以是静态也可以是非静态
- 成员可以是静态也可以是非静态
- 只能访问外部类静态成员
其中静态方法在类加载的时候就存在,静态成员和静态语句块则是在类初始化的时候才被初始化
静态方法必须要有实现,只能访问所属类的静态成员,方法中不能有this,super因为他们都对应了具体实例
final
final类不能被继承,final方法不能被重写,final变量值无法改变
容器
HashMap 11
HashMap
JDK1.8之前HashMap是由数组加链表的结构实现的,数组是主体,链表是用来解决哈希冲突而存在的(拉链法)。JDK1.8以后,如果链表长度大于8,会转化为红黑树;但是如果数组长度小于64,则会选择先对数组进行扩容,而不是转化为红黑树。使用红黑树是为了减少搜索时间。
HashMap会使用扰动函数处理key的hashcode得到一个hash,根据这个hash决定插入位置,扰动函数可以减少碰撞。JDK1.8的操作是无符号右移16位然后异或。
初始容量为16,加载因子0.75,扩容是翻倍。当元素个数超过容量的四分之三(0.75)会进行扩容。
线程不安全
线程不安全主要考虑到多线程情况下扩容会出现HashMap死循环,由于「扩容会建个新哈希,然后把数据从老的hash表迁移」这个过程。
快速失败fail-fast机制:多个线程改变集合结构的时候程序会抛出CocurrentModificationException异常,发生fail-fast
大小和扩容
默认的初始化大小是16,之后每次扩容会变为原来的两倍;如果设置了初始值,会把其扩容到2的幂次方大小。扩容是指对数组长度扩容,数组是主体。但是判断是否要扩容,是根据map中元素的个数与threshold进行比较,threshold是当前容量乘以加载因子
扩容的时机是:在插入一个新元素时,元素数量达到阈值,且插入时发生哈希冲突,会对数组进行扩容
为什么大小是2的幂次
计算数组下标一般会考虑到取余。有一个规律是:
hash % length == hash & (length - 1)
也就是一个数与除数取余等于这个数和比除数小1的数进行与操作,但是前提是除数为2的幂次。因为数组下标的计算方式选择 (n - 1) & hash,所以 n 要满足是2的幂次。与操作运算速度更快。
并且resize之后,元素要么在原位置,要么在原位置移动2次幂的位置,不用每个元素都移动位置
HashMap如何计算下标
用扰动函数hash计算key的hashcode得到一个hash,方法是将原来hashcode无符号右移16位然后异或。之后对hash进行取模运算,函数中的运算方法是 hash & (length - 1) length为数组长度
CocurrentHashMap下标空如何操作
自旋判断数组下标内容是否为空,如果为空,通过 CAS 操作将创建的 EntrySet 放入下标
插入方法
1.7是头插法,1.8是尾插法
遍历方式
- 迭代器 entrySet 遍历,同时获取key和value
- 迭代器 keySet 遍历,只获取key,value通过key来get
- ForEach entrySet 同上
- ForEach keySet 同上
- lambada表达式遍历
- Streams API 进行遍历
HashMap和HashTable的区别
- HashMap是线程不安全的;HashTable使用了synchronized关键字,是线程安全的
- HashMap允许null作为Key;HashTable不允许null作为Key,也不允许value为null
底层数据结构还是数组加链表/红黑树的形式。
在JDK1.8之前是由Segment数组 + ReentrantLock来实现的,Segment数组将一个大table分割成多个小table来进行加锁,每个Segment元素存储的是HashEntry数组和链表(和HashMap结构一样,是一个双层结构),会有分段锁锁住一个Segment,多线程访问不同数据段的数据,就不会存在锁竞争,提高并发率。Segment 最多有 16个,HashEntry最小容量为2。在这个版本需要经过两次 hash 才能到达指定 hashEntry,第一次先到达 Segment,第二次再到达 Segment 中的 hashEntry,然后再遍历 entry 链表。扩容时会扩容 EntrySet,Segment 数组的长度不会变
JDK1.8之后摒弃了Segment概念,直接使用 Node数组 + 链表 + 红黑树的数据结构来实现,并发控制使用 sychronized 和 CAS,sychronized只锁定当前链表或者红黑树的首节点,只要不发生哈希冲突,就不存在并发问题。不过 Node 只适用于链表情况,转换为红黑树后要转为TreeNode
HashTable也是线程安全,但是只用一把锁,会出现锁竞争,效率低下
如何线程安全地使用HashMap
HashMap线程不安全是因为两个进程同时进行put操作可能导致数据被覆盖;多个线程同时进行扩容还可能导致死循环,Node形成环状数据结构
HashTable是线程安全的,实现方式是 get 和 put 方法加了sychronized 关键字保证线程安全,但是所有线程竞争一把锁,效率很低
CocurrentHashMap把数据分段,每段数据配一把锁,一个线程占用一段数据时,其他线程还可以访问其他数据,并发性更高。1.8中加锁粒度细分到桶。
Sychronized Map中对每个方法加了sychronized关键字来保证线程安全,也是对整个表加锁
Hashcode方法 3
hashCode()方法默认是对堆上的对象产生独特值,是对象地址的一个映射。如果不重写hashCode(),即使两个对象指向相同的数据,也不会有相同的哈希值。
在重写equals方法的同时,必须重写hashCode方法
ArrayList和LinkedList区别 3
- 两者都不保证线程安全
- ArrayList底层使用Object数组;LinkedList使用双向链表
- ArrayList在随机存取方面效率高于LinkedList
- LinkedList在节点的增删方面效率高于ArrayList
- ArrayList的空间浪费主要是因为尾部会预留一定空间;LinkedList则是每个节点都需要额外保存信息,前驱后驱节点
遍历长度很大的数组和链表,哪个效率更快
数组,局部性原理,数组中的元素是连续的,周围的数据会提前加载到高速缓存cache中来
HashSet 2
实现Set接口,底层基于HashMap实现,对象不重复。因为底层基于HashMap,所以扩容机制也是一样的
加入对象时会先计算对象的哈希值来判断对象加入的位置,同时与其他已加入对象的hashcode比较,如果没有相等的,就默认没有重复出现;如果发现有相同的hashcode,再调用equals()判断两个对象是否等价,如果等价,添加操作就不成功。
泛型 2
- 泛型就是把属性的类型进行参数化,数据的类别也能像参数一样从外部传入
- 提供了类型检测机制,只有相匹配的数据才能正常赋值,否则编译无法通过
泛型是通过类型擦除实现的,泛型信息只存在于代码编译阶段,在进入JVM前所有类相关的信息会被擦掉,在运行时是无法访问到类型参数的。是为了与之前版本的代码兼容。
通配符
限定通配符:<? extends T> 必须是T的子类 <? super T> 必须是T的父类
非限定通配符:<?> 经常与容器类配合使用,所涉及的操作肯定和具体类无关
哈希冲突 2
-
拉链法,将冲突值加到链表中。JDK1.8之后会在一定条件下把链表转为红黑树来减少搜索时间
-
使用扰动函数重新计算key的哈希值(避免哈希冲突)
Map和List
Map是和Collection并列的所有集合框架的上层接口,List和Set是Collection的子接口。
Vector 和 ArrayList
底层,扩容为什么是1.5倍
Vector 和 ArrayList 都是 List 的实现,前者是古老实现类,底层都是Object数组,Vector线程安全,ArrayList线程不安全
Vector扩容默认2倍,ArrayList是1.5倍
默认大小是10
为什么扩容是1.5倍,因为函数中
1 | //将oldCapacity 右移一位,其效果相当于oldCapacity /2, |
加上原本容量右移一位后的值,就相当于加上原来值的二分之一,变成1.5倍
并发
进程和线程的区别 16
- 进程是一个执行中的程序,是系统进行资源分配的基本单位
- 线程是进程的一个实体,一个进程中一般会有多个线程,他们共享进程的共享地址空间和其他资源(Java中是堆和方法区资源)
- 线程一般不拥有系统资源,但是ThreadLocal会存储一些线程的专有资源(Java线程有自己的程序计数器、本地方法栈和虚拟机栈)
- 线程的上下文切换比进程快很多
线程的上下文切换快是因为线程切换只需要保存和设置少量寄存器内容,但是进程切换需要涉及当前CPU环境的保存和新运行进程CPU环境的设置。
任务从保存到再加载的过程就是一次上下文切换
守护线程
- 虚拟机要确保用户线程执行完毕
- 虚拟机不必等待守护线程执行完毕
- 设置 thread.setDaemon(true)
- 例如 GC 线程
线程状态
线程状态包括 新建态,运行态,阻塞态,消亡态
- NEW 线程被创建但是还没有调用start方法启动
- Runnable 创建线程并启动后就处于Runnable状态,此时有可能正在运行Running(运行),也可能在等待CPU资源(Ready就绪)
- Blocked 表示线程阻塞于锁
- Waiting 在等待另一个线程执行一些操作后将其唤醒(执行wait方法后进入waiting,收到notify后被唤醒)
- Time_waiting 与waiting类似,不过有明确等待时间,在到达等待时间会自动返回(执行了sleep(second) wait(second)方法)
- Terminated 表示线程执行完毕后关闭
sleep()和wait()的区别
- 都能暂停线程的执行
- sleep是Thread类的方法;wait是Object类的方法
- sleep没有释放锁;wait会释放锁
- wait常用于线程间交互通信;sleep用于暂停线程执行
- wait()方法调用后需要其他线程使用notify()方法唤醒。sleep()方法执行完后线程会自己苏醒。wait()也可以设置时间实现超时自动苏醒。
blocked和wait的区别
blocked阻塞于锁;wait是等待另一个线程执行一些操作后将该线程唤醒(notify)
可不可以直接调用run()方法而不通过start()方法
调用start()方法才会启动一个线程并让线程进入就绪态。直接运行run()方法只会把run()方法当成main进程下的一个普通方法去执行,并不是多线程。
线程安全
多线程环境下的线程安全主要体现在原子性,可见性和有序性方面
原子性
对于涉及到共享变量访问的操作,如果在外部变量看来操作是不可分割的,那操作就具有原子性。即其他线程不会看到执行操作的中间部分结果。例如转账流程中,外部只能看到A账户少100,B账户多100;而不会看到A账户少100,B账户不变的情况
- Java中 synchronized 关键字可以保证代码片段的原子性
可见性
指一个线程更新了变量后其他线程可以立马看到修改后的最新值。 volatile 关键字可以保证共享变量的可见性
Java 线程可能将变量的值保存在本地寄存器来读取。如果一个线程在主存中修改了变量值,而另一个线程还是读它本地寄存器中变量的值,造成数据不一致。volatile 指示 JVM 每次读取变量都去主存读。
每当 volatile 变量修改时,会通过总线将值推送到主存,其他线程会嗅探总线,如果发现本地缓存了这个变量的值,就将缓存置为不可用,下一次读取变量值时要去主存读取。
volatile 防止指令重排序,是在编译阶段
- 在写前面加上 STORE STORE 禁止写写重排序
- 在写后面加上 STORE LOAD 禁止写读重排序
- 在读前面加上 LOAD LOAD 禁止读读重排序
- 在读后面加上 LOAD STORE 禁止读写重排序
有序性
指代码在执行过程中的先后顺序。因为 Java 的优化可能导致执行顺序未必是编写代码时的顺序。 volatile 关键字可以禁止指令重排。
sychronized 8
sychronized是一个内部锁,它使用在方法和语句块上。它可以保证被它修饰的方法或语句块在任意时刻只能有一个线程执行。
是重量级锁,为什么重量级?因为每次发生冲突都会把线程阻塞,而每次阻塞和唤醒都要 CPU 在用户态和内核态之间进行切换,上下文切换开销大。
最主要的三种使用方式
修饰实例方法:作用于对象实例加锁,进入同步方法前要获得当前对象实例的锁
修饰静态方法:给当前类加锁,会作用于类的所有实例。如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。是两把不同的锁
修饰语句块:指定加锁对象
内部锁底层实现
- sychronized 同步语句块
- 进入时,执行 monitorenter指令 ,计数器+1,获取到 monitor 锁,执行monitorexit 指令后,计数器 -1,归为0,表明锁被释放。
- 当一个线程判断计数器为0时,当前锁空闲,可以占用;反之,当前线程进入阻塞等待状态
- sychronized 修饰方法
- 给方法添加一个ACC_SYNCHRONIZED标示,表明该方法是一个同步方法,从而执行相应同步调用。线程在执行方法前会先去尝试获取对象的 monitor 对象,完成之后释放 monitor 对象;如果 monitor 已经被其他对象获取,当前线程被阻塞
sychronized 和 ReentrantLock 的区别
- (ReentrantLock就是可重入锁的意思)两者都是可重入锁,可重入锁就是指在获取到锁之后,在内部还可以多次获取到锁,这样在递归调用带锁的方法时就不会发生死锁
- sychronized 是一个修饰符,JVM实现的; 而 ReentrantLock 是JDK实现的,是一个显式加锁
- ReentrantLock等待可中断,通过 lock.lockInterruptibly() 可以让线程放弃等待转而去执行其他事,或者设置一个超时时间,可以通过这个特性来避免死锁;synchronized 不可以
- ReentrantLock 可以实现公平锁(默认是非公平锁),就是先等待的线程先获得锁;synchronized 是非公平锁
公平锁是指多个线程按照申请顺序来获得锁;非公平锁是指线程先尝试插队获取锁,获取失败再进入队列排队
synchronized 和 volatile 的区别
- volatile 只能用于修饰变量;synchronized 可以修饰方法和代码块
- volatile 可以保证数据的可见性,但不能保证原子性; sychronized 两者都能保证(因为加了互斥锁)
- volatile 主要是解决变量在多个线程间的可见性;synchronized主要解决的事多个线程访问资源的同步性
volatile 5
语义,虚拟机如何实现
volatile 关键字是一个轻量级的锁,主要保证数据可见性,但是不保证原子性。声明一个变量是不稳定的,要求放弃从本地寄存器读取值,而是每次读取都去主内存读取。
加上 volatile 关键字之后会多一个 lock 指令,它相当于一个内存屏障,可以阻止指令重排,还会立刻将对变量的修改操作写入主存。
内存屏障
写操作
在每一个 volatile 写操作前面插入一个 storestore 屏障
在每一个 volatile 写操作后面插入一个 storeload 屏障
storestore 屏障 禁止上面的普通写与下面的 volatile 写重排序
storeload 屏障 禁止上面的 volatile 写与下面可能有的 volatile 读/写重排序
读操作
在每一个 volatile 读操作后面插入一个 loadload 屏障
在每一个 volatile 读操作后面插入一个 loadstore 屏障
loadload 屏障 禁止上面的 volatile 读与下面的普通读重排序
loadstore 屏障 禁止上面的 volatile 读与下面的普通写重排序
Threadlocal 3
通常我们创建的每个变量是可以被任何线程访问并修改的。ThreadLocal 是一个创建线程局部变量的类。
在主线程中创建一个Threadlocal变量,在其他线程中访问这个变量,都只能通过这个threadlocal变量访问到属于当前线程自己的变量,一个 threadlocal 对象获取到的值就是当前线程的局部变量
对于以下代码,thread1 中设置 threadLocal 为 1,而 thread2 设置 threadLocal 为 2。过了一段时间之后,thread1 读取 threadLocal 依然是 1,不受 thread2 的影响。
1 | public class ThreadLocalExample { |
底层原理
Thread 类中有两个 ThreadLocalMap 类型的变量: threadLocals 和 inheritableThreadLocals
- ThreadLocalMap可以理解为ThreadLocal定制化的HashMap
- 默认情况下这两个变量都为null,只有调用 ThreadLocal 类的 get 和 set 方法时才被创建
- 其实所调用的 get 和 set 方法就是 ThreadLocalMap 对应的 get set 方法
所以线程的局部变量其实是保存在当前线程的 ThreadLocalMap 中,ThreadLocal 类的变量只是一个接口,用来访问当前线程的 ThreadLocalMap 中的数据。而 ThreadLocalMap 中存储的是以 ThreadLocal 为 key ,Object 对象为 value 的键值对,所以通过一个ThreadLocal 变量可以获取到一个线程的局部变量,如果要设置多个局部变量,就需要创建多个 ThreadLocal 对象然后通过 set 赋局部变量值
ThreadLocal 内存泄露
会出现内存泄露是因为 ThreadLocalMap 中的 key 为 ThreadLocal 的弱引用,而 value 是强引用。所以如果 ThreadLocal 没有被外部强引用,在下一次GC回收的时候就会被清理掉,而 value 没有被清理,就会出现很多 key 为 null 的 Entry,如果不采取任何措施, value 永远不会被 GC 回收,就会发生内存泄漏。
TreadLocalMap 在调用 get set remove 方法的时候会清除 key 为 null 的记录,所以使用完 ThreadLocal 方法后最好手动调用remove 方法
CAS 3
传统的加锁方式 synchronized 和 ReentrantLock 叫做互斥同步,又叫阻塞同步,这种同步方式最主要的问题就是线程阻塞和唤醒带来的性能问题
CAS 相比之下是一种乐观方式,争用失败的线程不会被阻塞挂起。CompareAndSwap 函数会进行一次比较,比较内存中 object 的value 是否和预期的 expect (即最初获取到的 value)相等,如果相等,就证明没有其他线程改变过这个变量,就更新它为 update,否则就采用自旋的方式继续进行 CAS 操作
底层使用 JNI 调用 C 代码实现的,这个操作其实是 JNI 调用一个CPU指令完成的,所以具备原子性
但是有 ABA 问题存在,就是变量初次读取的是 A 值,后来被改为 B,后来又被改为 A,CAS就会认为这个变量从来没被改变过
Java锁 3
锁升级
是针对 synchronized 的锁优化
锁升级过程:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
自旋锁
自旋锁的思想就是让线程在请求一个共享数据的锁时循环一段时间,如果在这段时间获取到了锁,就可以避免被阻塞。虽然能避免线程进入阻塞而减少开销,但是会占用CPU时间,所以适合那些锁定状态很短的共享数据。
JDK1.6引入了自适应的自旋锁,自旋次数不再固定,而是由上一次在该锁上的自旋次数和使用者的状态决定
无锁
线程修改对象时不获取对象锁,而是通过 CAS 操作来修改对象
偏向锁
当第一个线程获取对象锁成功后,会通过 CAS 操作在对象头中记下锁偏向的线程 ID,该线程在之后进入同步块时如果检测到对象头中的锁偏向 ID 等于线程 ID,就可以直接进入同步块。当处于偏向锁状态的对象被第二个线程访问,偏向锁会撤销,升级成轻量级锁
轻量级锁
轻量级锁使用 CAS 操作来避免使用互斥量的开销,先用 CAS 进行同步,自旋失败后会升级为重量级锁
锁消除
如果检测出共享数据不可能存在竞争,就把锁消除
锁粗化
如果有一串连续的操作对一个对象加锁解锁,就会把锁的范围扩大到整个序列之外,避免反复加锁解锁
synchronized的执行过程:
- 检测Mark Word里面当前线程是否是偏向锁
- 如果不是(处于无锁状态),使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
- 如果是,检查锁偏向是不是当前线程的ID
- 如果是,直接进入语句块
- 如果不是,说明有第二个线程竞争锁,偏向锁状态结束,升级为轻量级锁
- 轻量级锁时如果线程想要获取锁,使用 CAS 将对象头的 Mark Word 替换为锁记录指针
- 如果成功,当前线程获得锁
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋失败,轻量级锁升级为重量级锁。
作者:自负的鱼
链接:https://www.jianshu.com/p/704eb56aa52a
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
线程创建 2
-
Thread 类
- 继承 Thread 类并重写 run() 方法;
- 创建线程对象
- 调用该对象的 start() 方法启动线程
-
Runnable 接口
- 定义一个类实现 Runnable 接口,并重写接口中的 run() 方法
- 创建类实例,将其作为创建的 Thread 类的 target 参数, Thread 对象才是真正的线程对象
- 调用 Thread 对象的 start() 方法启动线程
-
Callable 和 Future
Callable 接口提供了 call() 方法作为线程的执行体,相比 run() ,它可以有返回值,还可以抛出异常。Future 接口可以用来接受 call() 方法中的返回值。
Callable 不是 Runnable 子接口,不能作为 target 传入 Thread,RunnableFuture 接口解决了这个问题。它是 Future 接口和 Runnable 接口的子接口,该接口的实现类可以作为 target 参数传入。其中 FutureTask 就是官方的实现类,可以作为 target 参数
- 定义一个类实现 Callable 接口,并重写 call() 方法
- 创建 Callable 实现类的实例,并用 FutureTask 类的实例包装
- 将 FutureTask 的实例作为 target 传入 Thread 对象, 并启动线程
- 调用 FutureTask 对象的 get() 方法获取返回值
- 线程池
三种方法的区别
采用 Runnable 和 Callable 接口实现
- Thread 只是实现了接口,还可以继承其他类
- 同一个 target 可以共享给多个线程,适合让多个线程操作同一份资源
- 线程池只能放入 Runnable 和 Callable 接口的实现类,不能放入 Thread 子类
线程池参数 2
看这一篇就够了 https://juejin.im/post/5d1882b1f265da1ba84aa676
线程池作用
- 线程池帮助管理线程,减少创建和销毁线程的资源损耗
- 任务到达直接从线程池取线程,相比创建线程响应更快
- 线程用完再放回池子,可以复用
核心参数
- corePoolSize: 线程池核心线程数最大值
- maximumPoolSize: 线程池最大线程数大小(任务队列满后会创建非核心线程)
- keepAliveTime: 线程池中非核心线程空闲的存活时间大小
- unit: keepAliveTime 的时间单位
- workQueue: 存放任务的阻塞队列
- threadFactory: 用于设置创建线程的工厂,可以给创建的线程设置有意义的名字,可方便排查问题。
- handler: 线城池的饱和策略事件,主要有四种类型:默认直接抛异常;丢弃任务;
任务执行流程:
- 收到一个任务,先检查核心线程池是否已满,如过没满,创建核心线程执行任务
- 如果核心线程池满了,看队列是否满,如果没满,把任务放入队列
- 如果队列满了,查看线程池是否满了,如果没满,创建一个非核心线程执行任务
- 如果线程池也满了,无法执行任务
如何判断线程是否空闲?
Worker 线程继承 AQS,通过不可重入锁的特性来反应线程当前状态。线程池会通过 tryLock() 方法来判断线程是否空闲,如果没有获取到锁,说明线程在执行任务;反之则说明线程空闲
线程池如何管理线程的声明周期?
线程池通过一张 Hash 表维持对线程的引用,通过添加或移除引用的方式来控制线程的生命周期,线程的销毁依赖于 JVM 的自动回收。
通过 ThreadPoolExcutor 创建线程池,常见的线程池有:
-
newFixedThreadPool
一个线程数量固定的线程池,核心线程数和最大线程数一样
如果新任务提交时线程池中有空闲线程,就执行任务;如果没有,就放到一个任务队列中,等到线程空闲执行队列中的任务
使用了无界阻塞队列 LinkedBlockingQueue 如果任务越积越多,会导致内存飙升最后OOM
适用于CPU密集型任务,适用于执行长期任务
-
newSingleThreadExcutor
只有一个线程的线程池,进入的任务按照一定顺序依次执行
适用于需要串行执行任务的场景
-
newCachedThreadPool
没有核心线程,收到任务先放入阻塞队列,如果池中有空闲线程就使用,没有就创建新线程,线程空闲60秒后会被销毁
因为没有线程数量限制,如果一次性有很多任务,会导致创建很多线程,内存飙升
用于并发执行大量短期小任务
AQS
AbstractQueuedSynchronizer 是一个在 JUC 之下的类,JUC 包中的很多同步类例如 ReentrantLock 都是基于 AQS 实现的,内部使用了模版设计方法,方便自定义类的扩展
内部的两个核心成员是
- state 意为同步状态,用于展示当前临界资源的获锁情况,0 代表没有线程占用当前资源。线程尝试获取资源时会通过 CAS 操作修改 state 值。AQS 类 中用 volatile 关键字声明来确保 state 的可见性
- FIFO 双向队列,存放了当前占用的线程以及等待中的线程。其中的 head 节点是虚节点,不表示任何线程,头节点后的第一个节点才是有效节点
队列中节点获取锁的场景
- 判断前置节点是否为 head 节点
- 如果是,说明当前节点是第一个有效节点,尝试获取锁,如果成功获取到锁,将当前节点置为 head 节点
- 如果获取锁失败,或者前一个节点不是 head 节点,需要查看前驱节点的 waitStatus 来判断是否要将当前线程阻塞
- 如果为 -1,说明前置节点处于唤醒状态,无法获取锁,阻塞当前线程
- 如果大于 0,说明前置节点取消等待,循环查询前面所有取消状态的节点,将其从队列中移除
- 如果都不是,设置前置节点 waitStatus 为 -1,不阻塞当前线程
JUC包下的类
各种基本类型的原子类:AtomicInteger,AtomicLong,AtomicBoolean
线程安全的容器:CopyOnWriteArrayList,CocurrentHashMap,BlockingQueue等
- CopyOnWriteArrayList:写操作在一个复制数组上进行,完成后将原数组指向新的复制数组。写操作需要加锁,防止并发写入导致数据丢失。缺陷是:增加内存占用,以及写操作的数据没有同步到读数组时,会导致读写不一致。
一些锁:ReentrantLock
一些工具:Semaphore信号量
JVM
内存结构 6
内存结构
公共部分为
- 堆
- 方法区
- 直接内存
线程私有的
- 程序计数器
- 本地方法栈
- 虚拟机栈
具体功能
程序计数器
记录的是正在执行的那条字节码指令的地址,不会出现OOM
- 字节码解释器通过改变计数器的值来依次读取指令
- 多线程下,程序计数器记录当前线程的执行位置,切换回来之后能从上次的位置继续执行
虚拟机栈
Java 虚拟机栈为每个即将运行的 Java 方法创建一个栈帧,每个栈帧中中有局部变量表、操作数栈等信息。
局部变量表随着栈帧的创建而创建,大小在编译时就确定,运行时大小不变,其中存放了8种基本数据类型和堆内存中对象的引用变量
栈顶的栈帧是当前正在执行的方法对应的栈,只有这个栈帧中的局部变量能被操作数栈使用,如果方法中调用了新的方法,就压一个新栈在原来的栈之上,执行新栈;方法结束后,栈帧被移除,如果有返回值,会作为之后栈的操作数栈中的一个操作数
会出现的问题
- 如果栈内存不能动态扩展,当线程请求栈的深度超出当前栈的最大深度时,会出现StackOverFlowError
- 如果栈内存可以扩展,但是线程请求时内存用完了,就会出现 OOM
本地方法栈
和虚拟机栈类似,区别是虚拟机栈是为 Java 方法服务,而本地方法栈是为本地方法服务。
堆
是 Java 虚拟机管理内存中最大的一块,主要作用就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存。这里也是垃圾回收的主要场所。
分代就是在这块区域,可以被细分为新生代和老年代,分代是为了更好地回收内存,更快地分配内存
方法区
方法区中主要存放已被加载的类信息,还存有常量、静态变量和 JIT 编译后的代码。
JDK 1.8 之后,方法区(永久代)被移除,原先的数据,被加载的类信息被放到元空间(使用直接内存)、静态变量和常量池被放入堆内存。
运行时常量池是方法区的一部分,存放一些编译期间生成的字面量和类的符号引用
符号引用就是字符串,通过这个字符串可以定位到指定数据,直接引用就是指向地址
垃圾回收 6
GC算法和垃圾收集器,GC如何分代,为什么要分代(根据分代使用不同的垃圾收集算法,提高内存回收的效率)
GC 分代
分为新生代和老年代
新生代分为 Eden、From Survivor、To Survivor,Old Memory区属于老年代
大多数对象在新生代Eden区分配内存,当Eden区内存不够时,虚拟机将发起一次Minor GC
- Minor GC:回收新生代(包括Eden 和 Survivor),Minor GC 发生频繁,并且速度很快
- Major / Full GC:回收老年代,速度很慢。发生 Full GC 往往是老年代空间不足(大对象进入老年代)或者出现分配担保失败
大对象直接进入老年代,避免在 Eden 和两个 Survivor 区发生大量内存拷贝,降低效率
长期存活的对象进入老年代,每进行一次 Minor GC,存活下的对象年龄加1,当年龄大于晋升年龄,就被转移到老年区。晋升年龄因垃圾收集器的不同而不同。
动态年龄判定,如果 Survivor 区中相同年龄的对象大小大于 Survivor 空间的一半,把年龄大于等于这个数字的对象都移入老年区
空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大连续空间是否大于新生代所有对象总空间,如果大于, Minor GC 可以确认安全
如果不成立,并且不允许分配担保,就会进行Full GC ;如果允许分配担保,会检查老年代连续内存空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC;如果小于,进行 Full GC
如何判断对象已经死亡
-
引用计数法
对象头维护一个计数器,被引用,计数器+1,引用失效,计数器-1,如果计数为0该对象就被判定无效
JVM 没有实现这个方法,因为无法解决循环引用,如果两个对象互相引用,就无法被回收
-
可达性分析法
以 GC Roots 为起点进行搜索,可达的对象都有效,不可达对象被回收
GC Roots 包括:
- 虚拟机栈局部变量表引用的对象
- 本地方法栈JNI引用的对象
- 方法区类静态属性引用的对象
- 方法区常量引用的对象
用finalize()方法可能可以自救(在该方法中使用 this 让这个对象重新被引用),如果finalize方法没有被重写,或者已经被执行过一次,就真的回收对象了。
回收方法区
-
常量的回收:如果常量没有被引用,就会被清除
-
类什么情况下会被回收
-
该类的所有实例已被回收
-
加载该类的 classLoader已被回收
-
该类的Class对象没有被引用,无法通过反射访问该类的方法
垃圾收集算法
标记-清除
- 标记:遍历GC Roots,将可达的对象标记为存活对象
- 清除:遍历堆中所有对象,将没有标记的对象清除,将标记过对象的标记清除
- 不足:标记和清除的效率不高;会产生大量不连续的内存碎片
复制算法(新生代)
- 思想是把内存等分为两块,其中一块进行垃圾收集后,把存活的对象复制到另一块,清除原来的区域。但是这样可用内存减少了一半
- 在新生代中把内存分为三块区域,Eden, From Survivor,To Survivor,比例8:1:1. 每次使用Eden和其中一块Survivor,然后把存活的对象复制到另一块Survivor,清除剩余两块区域
标记-整理(老年代)
- 标记:第一阶段也是遍历GC Roots,将存活的对象标记
- 整理:将所有存活的对象向一端移动,按地址排序,然后把末端内存地址以后的内存全部回收
因为老年代每次都会有大量对象存活,如果采用复制算法开销很大
分代收集算法
新生代使用复制算法;老年代使用 标记-清除 或者 标记-整理 算法
垃圾收集器
Serial 收集器
单线程进行垃圾收集,会暂停所有用户线程
ParNew 收集器
Serial的多线程版本
Parallel Scavenge 收集器
多线程,关注吞吐量
Serial Old 收集器
老年代版 Serial
Parallel Old 收集器
老年代版 Parallel Scavenge
CMS收集器
追求低停顿的收集器,在垃圾收集时使用户线程和GC线程并发执行,用户不会感到明显停顿
- 初始标记:Stop The World,仅对GC Roots直接关联的标记进行标记,速度很快
- 并发标记:使用多条标记线程,与用户线程并发执行。进行可达性分析,标记所有可达对象,
- 重新标记:重新标记新分配到老年代的对象以及并发阶段被修改了的对象
- 并发清除:开启用户线程,同时GC线程对未标记的区域进行清理
缺点是:吞吐量低;无法处理浮动垃圾;标记-清除算法会产生大量内存碎片
吞吐量低是因为并发阶段,虽然不会导致用户线程停顿,但是因为 GC 线程占用了一部分 CPU 资源导致应用程序变慢,最后导致吞吐量降低
浮动垃圾是因为并发清除阶段用户线程继续运行而产生的垃圾,这部分垃圾只能到下次GC时才能回收,因此收集器一般需要预留出20%的空间存放浮动垃圾。如果预留的空间不够存放浮动垃圾,会临时启用Serial Old进行垃圾清理。
将堆划分为若干个大小相等的 Region,每格块中的内存是连续的。G1 中每个块也会充当Eden,Survivor,Old。跟踪每个区域垃圾堆积的价值大小,在后台维护一个优先列表,每次回收优先级最高(根据回收获得的空间大小和回收所需的时间得出)的区域
- 初始标记:标记 GC Roots 能直接关联到的对象,这一段需要 STW ,但其实是进行 Minor GC 的时候同步完成的,所以并没有额外停顿
- 并发标记:
- 最终标记
- 筛选回收
高吞吐,低停顿,基于标记整理算法,不会产生空间碎片
类加载 3
类加载机制(过程、类加载器、类初始化、实例化),双亲委派
类加载
包括 加载、验证、准备、解析、初始化5个阶段
加载
- 通过类的完全限定名获取到定义该类的二进制字节流(class文件)
- 将二进制字节流代表的静态存储结构转化为方法区的运行时存储结构
- 在内存中生成一个代表该类的Class对象,用于作为方法区中该类各数据的访问入口
验证
- 确保 Class 文件的内容符合虚拟机规范
准备
- 为类变量分配内存和初始化值
- 普通类变量初始化值为零值,final类变量直接赋定义的值
解析
- 将运行时常量池的符号引用替换为直接引用的过程,也就是获得类或者方法在内存中的指针或者偏移量
初始化
- 初始化就是真正执行类中定义的 Java 程序代码,初始化静态变量的值,执行静态语句块
初始化的时机
- 遇到new,getstatic,putstatic,invokestatic这四条指令时
- 对类进行反射调用时
- 初始化一个类时发现父类还没有初始化时
- 虚拟机启动时先初始化包含main方法的类
Java 中内置了三个类加载器
- BootstrapClassLoader(启动类加载器) 最顶层的加载类,负责加载 JAVA_HOME/lib 下的类和jar包
- ExtensionClassLoader(扩展类加载器) 负责加载 JAVA_HOME/lib/ext 下的类和jar包
- AppClassLoader(应用程序类加载器) 面向用户的加载器,负责加载当前路径下的所有类和jar包
自定义类加载器 继承 ClassLoader,并重写 findClass()
抽象方法,其中
- 先通过
getClassByte()
方法读取自定义路径下的 Class 字节码文件得到 byte 数组 - 最后通过
defineClass()
获取到 Class 对象
不继承 AppClassLoader 是因为它和 ExtClassLoader 的默认访问权限是只能被同一个包下的类访问
双亲委派
要求除了顶层启动类加载器外,其余类加载器都要有自己的父类加载器(这里的父子关系不是继承关系,而是通过组合关系来复用父类加载器的代码)
如果一个类加载器收到了类加载请求,会先把请求委托个父类加载器执行,如果父类加载器还有父类,就继续向上委托,最终都会到达启动类加载器。如果父类加载器能够完成加载,就成功返回,如果父类无法加载,子类才会尝试加载。
使得同名类有优先级关系,上层加载器加载的类优先级更高,使用的就都是上层加载器加载的类。一个例子就是 Object 类。
两个类相等,需要类本身相等,并且是由同一类加载器加载
类实例化(Java 对象创建的过程)
-
类加载检查
一般通过 new 指令来创建对象,虚拟机遇见 new 指令时先根据指令参数看是否能在常量池中定位到某个类的符号引用,并且检查这个类是否已经被加载、解析和初始化。如果没有,会进行初始化过程。类加载完成后,会在堆中划分出一块内存,然后在这块内存上进行类的实例化。
-
内存分配
内存分配有两种方式,指针碰撞和空闲列表。这两种方式取决于堆内存是否规整,取决于垃圾收集器的算法是“标记-清除”还是“标记-整理”
-
指针碰撞:内存是规整的,用过的放一边,没用过的放另一边,只要沿着没用过的地方将指针移动一个对象的大小就行
-
空闲列表:内存不规整,但是虚拟机会维护一个列表记录哪些内存块可以用。内存分配时选择一块足够大的内存分配给对象,然后更新列表
如何保证线程安全:
- 采用 CAS + 失败重试,采用乐观的方式,假设没有冲突去完成某项操作,如果因为冲突失败,就重试
- TLAB 为每个线程在 Eden 区先分配一块内存,如果要为对象分配内存,先在 TLAB 尝试分配,这块内存不够时再使用上面的方法
-
初始化零值
将分配到的内存空间都赋0值,这样对象的实例字段在 Java 中不赋初值就可以使用
-
设置对象头
将 对象是哪个类的实例、对象的哈希码、GC分代年龄 等信息放入对象头
-
执行 init 方法
内存中的值全为0,将这块内存区域按照程序员的意愿进行初始化,才是一个真正可用的对象
对象访问时是如何被找到的
句柄访问:Java堆中会划分出一块内存来作为句柄池,引用变量存储的就是对象的句柄地址,其中存放了实例数据和类数据各自的具体地址,由于不是直接访问,速度较慢
直接指针:对象引用存储的就是对象在堆内存的地址,然后指向类信息的地址存放在对象地址中
四种引用类型
强引用类似 Object obj = new Object() 就是强引用,只要强引用存在,被引用对象就永远不会被GC回收。但是如果错误保持了强引用,就会导致对象一直不会被回收。例如声明了static,只要类不被回收,引用就会一直存在。
软引用相对强引用弱一些,只有内存不足时才会回收软引用对象。JVM会在抛出OutOfMemeryError之前回收软引用对象。软引用可以加速 JVM 对垃圾内存的回收速度。防止内存溢出
弱引用比软引用弱一些,JVM进行垃圾回收时,无论内存是否充足,都会回收弱引用指向的对象。
虚引用是最弱的一种引用关系,有无弱引用的存在不会对对象的生存周期产生影响。只是用于跟踪对象被GC回收的活动。和前几个引用的区别是,虚引用必须要和引用队列一起使用,当GC准备回收一个对象,如果发现它还有虚引用,就会在回收之前把虚引用加入到引用队列中。程序通过判断引用队列中是否有虚引用,来了解被引用的对象是否要被回收,从而采取相应措施