概念
java的特点
- 平台无关性:java编译器将源代码编译成功字节码之后,该字节码文件就可以在任何安装了java虚拟机(JVM)的系统上运行。 【不同系统上的JVM是不一样的】
- 面向对象:java是一门面向对象的编程语言,几乎一切都是对象,让代码更加易于维护和重用,包括类,对象,继承,抽象,多态和封装。
- 内存管理:java有自己的垃圾回收机制,自动管理内存和回收不再使用的对象。这样,开发者无需手动管理内存,从而减少内存泄漏和其他内存相关的问题。
java为什么是跨平台的?
java可以跨平台主要是因为JVM,JVM相当于一个中间件,是实现跨平台的关键。
JVM是一个软件(java虚拟机),不同的平台有不同的版本。我们需要编译java的源码,生成.class的文件,也叫做字节码文件。java虚拟机负责将字节码文件翻译成特定平台下的机器码然后运行。也就是说,只要在不同平台上安装对应的JCM,就可以运行字节码文件,运行我们编写的java程序。
JVM,JRE,JDK三者的关系
- JVM是java虚拟机,是java程序的运行环境,他会负责把java的字节码解释或者编译成功机器码,并执行程序,且jvm提供了内存管理,垃圾回收,安全性等功能,是的java程序具备跨平台性。
- JRE是java运行时环境,是java程序锁运行时的最小环境,她包好jvm和java核心库(java运行时所需要的类和接口)
如果你只需要运行Java应用程序,而不是开发它们,那么安装JRE就足够了。JRE确保了Java应用程序能够在不同的平台上以相同的方式运行,这是因为JRE为Java程序提供了一个独立于平台的运行环境。
- JDK是java开发工具包,是开发java程序所需的攻击集合。它包含了JVM,编译器(javac),调试器等开发工具,以及java库。
JDK是开发Java应用程序的核心,没有它,开发人员就无法编译和创建Java应用程序。
为什么JVM解释和编译都有?为什么java又是编译型语言又是解释型语言?
图中的紫色区域就是jvm
编译性:
- java源代码首先被java编译成字节码,JIT会把编译过的机器码保存下来以备下次使用。
解释性: - JVM中一个方法调用计数器,当累计计数大于一定值的时候,就使用JIT将进行编译生成机器码文件,否则就是使用解释器进行解释执行,然后字节码也是经过解释器进行解释运行的。
所以java即是编译型也是解释型语言,默认采用的是解释器和编译器混合的模式。
编译型语言和解释型语言的区别在于:
编译型语言:在程序执行之前,整个源代码会被编译成机器码或者字节码,生成可执行文件。执行时直接运行编译后的代码,速度快,但跨平台性较差。
解释型语言:在程序执行时,逐行解释执行源代码,不生成独立的可执行文件。通常由解释器动态解释并执行代码,跨平台性好,但执行速度相对较慢。
典型的编译型语言如C、C++,典型的解释型语言如Python、JavaScript。
为什么使用bigDecimal不用double?
double会出现精度丢失的问题,double执行的是二进制浮点运算,二进制有些情况下不能准确的表示一个小数,就像十进制不能准确的表示1/3(1/3=0.3333…),就是说二进制表示小数的时候只能够表示能够用1/(2^n)
的和的任意组合,但是0.1不能够精确表示,因为它不能够表示成为1/(2^n)的和的形式。
比如
浮点数精度丢失的原因是因为浮点数的表示方式以及计算机的有限存储。
- 浮点数的表示方式
浮点数在计算机中是通过二进制浮点数表示的,遵循的是 IEEE 754 标准。在这个标准下,一个浮点数是通过三部分表示的:
符号位:表示正负。
指数部分:表示数值的大小范围。
尾数部分(小数部分或有效数字部分):表示数值的精确度。- 二进制表示法的限制
与十进制系统(基数为10)不同,二进制系统(基数为2)只能精确地表示以 2 的幂为分母的分数。例如:
0.5可以表示成2的-1次方
0.25可以表示成2的-2次方
然而,像 0.1 这样的十进制小数无法用有限的二进制小数精确表示。- 举例说明精度丢失
为什么 0.1 无法被精确表示
0.1 在十进制中是一个有限小数,但在二进制中,它是一个无限循环小数:
0.1 的二进制表示是:
0.0001100110011001100110011001100110011…
0.0001100110011001100110011001100110011…(无限循环)。
由于计算机的存储是有限的,必须截断这个表示,这就导致了精度的丢失。计算机只能存储一个有限位数的二进制小数,而不是这个无限循环小数。这个截断过程引入了误差,使得存储的数值并不是我们期望的精确值。- 浮点数运算的误差
当进行浮点数运算(如加法、减法、乘法、除法)时,所有的运算结果都需要按照浮点数格式进行舍入。这种舍入操作会导致结果相对于“真实”数学结果的细微偏差。特别是当你执行大量浮点数计算时,这种误差会累积,造成“精度丢失”。- 总结:为什么会出现精度丢失?
浮点数的表示方式:浮点数用二进制表示,很多十进制小数在二进制中是无限小数,不能精确表示。
存储的有限性:计算机只能存储有限长度的二进制数,当小数是无限长时,计算机只能存储一个近似值。
计算的舍入误差:浮点数的运算结果需要在计算机中进行舍入,这种舍入引入了误差。
这些因素共同导致了浮点数在计算机中出现“精度丢失”的问题。这种误差通常是很小的,但在某些情况下(例如金融计算、科学计算),这种误差可能会引起问题,因此在需要高精度的场景下,通常会使用其他方法(如十进制浮点数、分数表示法、任意精度数)来避免这种精度丢失。
而 Decimal 是精确计算 , 所以一般牵扯到金钱的计算 , 都使用 Decimal
需要注意的是,在创建BigDecimal对象时,应该使用字符串作为参数,而不是直接使用浮点数值,以避免浮点数精度丢失。
拆箱和装箱是什么?
Integer i = 10;// 装箱
int n = i; //拆箱
自动装箱:
- 赋值的时候
Integer i = 10;// 装箱
- 方法调用的时候
test接收Integer对象作为参数,当调用test1(1)时,会把int值转换为对应的Interger,这就是自动装箱,当接收为int re时,发生了自动拆箱。
自动装箱的弊端
代码sum+=i可以看成sum = sum + i,但是+这个操作符不适用于Integer对象,首先sum进行自动拆箱操作,进行数值相加操作,最后发生自动装箱操作转换成Integer对象。内部变化如注解。
因为我们什么的数目为Integer类型,所以在下面的循环 会创建99个无用的Integer对象,如果循环再多一点,就会降低我们的程序性能,并加重垃圾回收的工作余量。所以我们在实际中,需要正确声明类型,避免自动装箱引起的性能问题。
Java为什么要有Integer?
Interger对应是int类型的包装类,就是把int类型包装成Object对象,对象封装有很多好处,可以把属性也就是数据跟处理这些数据的方法结合在一起,比如Integer就有parseInt()等方法来专门处理int型的相关数据。
异常
java异常类层次结构图
java的异常体系主要基于两大类:Throwable类的子类,分别为Error和Exception
- Error(错误):表示运行时环境的错误,错误是程序无法处理的验证问题,比如系统崩溃,虚拟机错误,动态链接失败等。通常,程序不应该尝试捕获这类错误,例如OutOfMemoryError
- Exception(异常):表示程序本身可以处理的异常条件。异常分为两大类:
**非运行时异常:**这类异常在编译时就必须被捕获或者声明抛出。他们通常是外部错误,如文件不存在,类找不到等,非运行是异常强制程序员处理这些可能出现的问题,增强了程序的健壮性。
**运行时异常,**这类异常包括运行时异常和错误。运行是异常由程序错误导致,如空指针访问,数组越界等。运行时异常是不需要再编译时强制捕获或声明。
java异常处理有哪些?
异常处理是通过使用try-catch语句块来捕获和处理异常。以下是java中常用的处理方式:
- try-catch语句块:用于捕获并处理肯呢个抛出的异常。try块中包含可能抛出异常的代码,catch块用于捕获并处理特定类型的异常。可以有多个catch块来处理不同类型的异常。
try{
}catch (Exception e){
}
- throw语句:用于手动抛出异常。可以根据需要再代码中使用throw语句主动抛出特定类型的异常。
throw new ExceptionType("Exception message");
- throws关键字:用于在方法声明中声明可能抛出的异常类型。如果一个方法可能抛出异常,但不想在方法内部进行处理,可以使用throws关键字将异常传递给调用者来处理。
public void methodName() throws ExceptionType {
//方法体
}
- finally块:用于定义无论是否发生异常都会执行的代码块。通常用于释放资源,确保资源的正确关闭。
try{
}catch (Exception e){
} finally {
// 无论是否发生异常,都会执行的代码
}
- 下面这条语句返回啥
try{
return "a"
}
finally{
return "b"
}
finally块中的return语句会覆盖try中的return返回,因此 该语句返回“b”。
== 和 equals 有什么区别?
8种基本数据类型
byte,short,int,long,float,double,boolean,char
==
如果是基本类型,比较的是值是否相等。
如果是引用对象,判断的是对象的内存地址
因为 Java 只有值传递,所以,对于 == 来说,不管是比较基本数据类型,还是引用数据类型的变量,其本质比较的都是值,只是引用类型变量存的值是对象的地址。
equals()
equals()不能用于判断基本数据类型是否相等,只能判断对象。
如果是字符串,判断的是字符串的字符内容是都相等。
如果是object对象,比较的是内存的地址值。
可以自己重写
hashCode()有什么用?
hashCode()的作用就是获取哈希码(int整数),也称为散列码。这个哈希码的作用是确定该对象在哈希表中的索引位置。
hashCode()定义在JDK的Object类中,这就意味着Java中的任何类都包含有hashCode()函数。
为什么要有hashCode?
以hashset如何检查重复为例子来说明为什么要有hashcode?
当吧对象加入到hashset时,hashset会先计算对象的hashcode值来判断对象加入的位置,同时和其他已经加入的对象的hashcode比较,如果没有相符的hashcode,hashset会假设对象没有重复出现;如果有相同的hashcode,这时就会调用equals()方法来检查hashcode相等的对象是否真的响应,如果两者相同了。HashSet 就不会让其加入操作成功。如果不同的话,就会重新散列到其他位置。这样我们就大大减少了 equals 的次数,相应就大大提高了执行速度。
为什么jdk要同时提供这两个方法呢?
这是因为在一些容器(比如 HashMap、HashSet)中,有了 hashCode() 之后,判断元素是否在对应容器中的效率会更高(参考添加元素进HashSet的过程)!我们在前面也提到了添加元素进HashSet的过程,如果 HashSet 在对比的时候,同样的 hashCode 有多个对象,它会继续使用 equals() 来判断是否真的相同。也就是说 hashCode 帮助我们大大缩小了查找成本。
那为什么不只提供hashcode方法呢?
这是因为两个对象的hashcode值相等并不代表两个对象就相等。
为什么两个对象相同的hashcode值,他们也不一定相等呢?
因为哈希算法可能会产生hash冲突,特别是在哈希算法比较糟糕的情况下。
为什么重写equals时必须重写hashcode
因为两个相等的对象的 hashCode 值必须是相等。也就是说如果 equals 方法判断两个对象是相等的,那这两个对象的 hashCode 值也要相等。如果重写 equals() 时没有重写 hashCode() 方法的话就可能会导致 equals 方法判断是相等的两个对象,hashCode 值却不相等。
如果你只重写了 equals() 方法而没有重写 hashCode() 方法,可能会导致以下问题:
**数据结构错误:**在使用哈希表(如 HashMap 或 HashSet)时,基于 equals() 方法判断对象相等的逻辑可能会失效。例如,如果两个对象通过 equals() 方法判断为相等,但它们的哈希码不同,那么哈希表无法正确识别它们是同一个对象,可能会导致数据结构的错误或重复元素的出现。
**哈希表性能问题:**如果 hashCode() 方法没有适当地实现,可能会导致哈希表中的元素分布不均,影响哈希表的性能,增加冲突的概率,从而降低操作效率。
StringBuffer和StringBuild区别是什么
java1.8新特性 Stream流
java8引入了stream API,他提供了一种高效且易于使用的数据处理方式,特别适合集合对象操作,如映射,过滤,排序等。Stream API不仅可以提高代码的可读性和简洁性,还能利用多核处理器的优势进行并行处理
public class StreamT {
public static void main(String[] args) {
List<String> o = Arrays.asList("apple", "fig", "banana", "kiwi");
List<String> collect = o.stream()
.filter(s -> s.length() > 3)
.collect(Collectors.toList());
System.out.println("字符串长度大于3的"+collect);
List<Integer> number = Arrays.asList(1,2,3,4,5);
int sum = number.stream().mapToInt(Integer::intValue).sum();
System.out.println("算和"+sum);
List<Integer> collect1 = number.stream().sorted().collect(Collectors.toList());
System.out.println("排序"+collect1);
}
}
Stream流的并行API是什么?
是ParallelStream
并行流(ParallelStream)就是将源数据氛围多个子流对象进行多线程操作,然后将处理的结果再汇总为一个流对象,底层是使用通用的fork/join池来实现,即将一个任务分成多个“小任务”并行计算,再把多个“小任务”的结果合并而成总的计算结果。
对cpu密集型任务来说,并行使用ForkJoinPool线程池,为每个cpu分配一个任务,这是非常有效率的,但是如果任务不是cpu密集型,而是I/O密集的,并且任务数相对线程数比较大,那么使用ParallelStream并不是很好的选择。
completableFuture怎么用的?
CompletableFuture是由java8引入的,在java8之前我们通常使用Future实现异步。
- Future用于表示异步计算的结果,只能通过阻塞或者轮询的方式获取结果,而且不支持设置回调方法,如果要使用回调方法一般会使用guava的listenableFuture还会产生回调地狱。
阻塞或轮询获取结果:Future 提供的方法(如 get())会阻塞当前线程,直到计算完成。这种方式不适合需要立即响应或处理多个异步结果的场景。
缺乏回调支持:Future 没有提供直接的机制来在异步计算完成时自动执行某些操作。这意味着我们必须手动检查任务的完成状态(如轮询)或阻塞等待。
如下所示
public class FutureT {
public static void main(String[] args) {
//创建一个大小为5的线程池 这个线程池始终会有5个线程在运行
ExecutorService executor = Executors.newFixedThreadPool(5);
//把ExecutorService拓展成ListeningExecutorService,可以使用它的回调
ListeningExecutorService guavaExecutor = MoreExecutors.listeningDecorator(executor);
//提交一个任务,返回一个字符串
ListenableFuture<String> future1 = guavaExecutor.submit(() -> {
// step 1
System.out.println("执行step 1");
return "step1 result";
});
//提交异步任务
ListenableFuture<String> future2 = guavaExecutor.submit(() -> {
//step2
System.out.println("执行step 2");
return "step2 result";
});
//把异步任务装在一个集合任务里面
ListenableFuture<List<String>> listListenableFuture = Futures.allAsList(future1, future2);
//添加一个回调,在异步任务执行完成之后执行
Futures.addCallback(listListenableFuture, new FutureCallback<List<String>>() {
@Override
public void onSuccess(List<String> result) {
System.out.println("两个都完成"+result);
ListenableFuture<String> future3 = guavaExecutor.submit(()->{
System.out.println("执行step 3");
return "step3 result";
});
Futures.addCallback(future3, new FutureCallback<String>() {
@Override
public void onSuccess(String result) {
System.out.println(result);
System.out.println("全部完成");
}
@Override
public void onFailure(Throwable t) {
}
},guavaExecutor);
}
@Override
public void onFailure(Throwable t) {
System.out.println("两个都失败");
}
},guavaExecutor); //指定执行回调的service
//关闭这个线程服务
guavaExecutor.shutdown();
}
}
CompletableFuture对Future进行了扩展,可以通过设置回调的方式处理计算结果,同时也支持组合操作,支持进一步的编排,同时一定程度解决了回调地狱的问题。
CompletableFuture的实现如下:
public class CompletableFutureT {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<String> cf1 = CompletableFuture.supplyAsync(() -> {
System.out.println("执行1");
return "1";
},executor);
CompletableFuture<String> cf2 = CompletableFuture.supplyAsync(() -> {
System.out.println("执行2");
return "2";
},executor);
//它用于在两个CompletableFuture都完成后
cf1.thenCombine(cf2,(r1,r2)->{
System.out.println(r1+r2);
System.out.println("执行3");
return "3";
}).thenAccept(r3 -> System.out.println(r3));
//thenAccept不会返回一个新的CompletableFuture而是表示计算链的结束
}
}
显然,CompletableFuture的实现更为简洁,可读性更好。
序列化
volatile和sychronized
- 保证变量的可见性:当一个共享变量被volatile修饰时,它可以保证修改的值会立即被更新到主内存中,当有其他线程需要读取该变量时,它会去主内存中获取最新的值,而不是使用本地缓存。这样可以确保所有线程都看到最新的变量值。
- 保证指令的顺序性:volatile关键字还可以保证指令的顺序性。在多线程环境下,由于指令重排序和处理器管线化的原因,指令的执行顺序可能会发生变化(没有依赖关系就可以重新排序)。但是,使用volatile关键字可以防止这种情况的发生。当一个共享变量被volatile修饰时,它会禁止指令重排序,确保指令按照程序顺序执行。
https://cloud.tencent.com/developer/article/2353578?from_column=20421&from=20421添加链接描述 指令重排
https://cloud.tencent.com/developer/article/2354060
class MyData2{
// Voliate int number = 0; 可见性
int number = 0;
public void addTo60(){
this.number = 60;
}
}
public class Voliate {
public static void main(String[] args) {
seeOKByVolatile();
}
/**
*
*/
private static void seeOKByVolatile() {
MyData2 myData = new MyData2();
new Thread(()->{
System.out.println(Thread.currentThread().getName()+"\t come in");
// 暂停线程,当前线程挂起,这时候main会抢占资源
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
myData.addTo60();
System.out.println(Thread.currentThread().getName()+"\t update number "+myData.number);
},"AAA").start();
// 第二个线程就是我们main主线程
while (myData.number == 0){
// 发现myData.number还是0,一直循环,3秒后,myData.number被修改,但是没有通知到主线程,造成可见性问题
// //这里不能输出 因为输出之后会重新更新缓存
// System.out.println("==========");
}
System.out.println(Thread.currentThread().getName()+"\t main is over ");
}
}
synchronized是一个关键字,lock是一个接口手动去获取锁释放锁,
synchronized可以对方法,代码片段,对象(只对这个对象带有synchronized的方法加锁)加锁
Syncronized 的目的是一次只允许一个线程进入由他修饰的代码段,从而允许他们进行自我保护。Synchronized 很像生活中的锁例子,进入由Synchronized 保护的代码区首先需要获取 Synchronized 这把锁,其他线程想要执行必须进行等待。Synchronized 锁住的代码区域执行完成后需要把锁归还,也就是释放锁,这样才能够让其他线程使用。
java怎么实现网络IO高并发编程
同步与异步
- 同步:同步就是发起一个调用之后,被调用者未处理完请求之前,调用不返回。
- 异步:异步就是发起一个调用之后,立刻得到调用者的回应,但是没有返回结果,此时可以去做其他事情,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。
阻塞与非阻塞 - 阻塞:阻塞就是发起一个请求,调用者就一直等待请求结果返回,也就是当前线程会一直等待,什么都不做。
- 非阻塞:非阻塞就是发送一个请求之后,可以去做其他的事情。
- 那么同步阻塞,同步非阻塞,异步非阻塞又代表什么意思呢?
同步和异步是对于调用方发送的请求来说 阻塞和非阻塞是对于接收请求的线程 这样理解对吗
BIO
同步阻塞IO模式,数据的读取写入必须阻塞在一个线程内等待其完成。
由于每个连接都需要一个独立的线程,因此在大量并发连接时会消耗大量的系统资源(线程数),容易导致系统性能下降甚至崩溃(“线程数”耗尽)。
NIO
NIO是基于io多路复用实现的,是一种同步非阻塞的IO模型,可以只用一个线程处理多个客户端IO,如果你需要同时管理成千上万的连接,但是每个连接只发送少量数据,例如一个聊天服务器,用NIO实现会更好。
同步是指线程不断轮询IO事件是否就绪,非阻塞是指线程在等待IO的时候,可以同时做其他任务。
而在NIO中同步的核心是Selector(IO多路复用),Select代替了线程本身轮询IO时间,避免了阻塞同时减少了不必要的线程开销,非阻塞的核心就是管道和缓冲区,当IO时间就绪之时,可以通过写到缓冲区,保证IO的成功,而无需线程阻塞式等待。
线程之间通过wait,notify通信,减少线程切换。
AIO
AIO 是异步 I/O 模型,它通过 Java NIO 2 中的异步通道(AsynchronousChannel)来实现。AIO 允许程序发起 I/O 操作后继续执行其他任务,而不需要轮询操作状态,当操作完成时会通知程序。这种模型适用于需要高度并发和异步操作的场景,但编程复杂性也相对较高。
集合
数组和集合的区别?
- 数组是固定长度的数据结构,一旦创建长度就无法改变,而集合是动态长度的数据结构,可以根据需要动态增加或者减少元素。
- 数组可以包含基本数据类型和对象,而集合只能包含对象。
- 数组可以直接访问元素,而集合需要通过迭代器或其他方法访问元素。【数组可以使用索引直接访问,集合只能遍历寻找】
List
- Vector是java早起提供的线程安全的动态数组,如果不需要线程安全,并不建议选择,毕竟同步是有额外开销的,Vector内部是使用数组来保存数据的,可以根据需要自动的增加容量,当数组已满时,会创建新的数组,并拷贝原有数组数据。
- ArrayList是线程不安全的动态数组,因为线程不安全,所以性能比较好,也可以动态扩容,不过在扩容上和Vector有所区别,Vector在扩容时会提高1倍,而ArrayList则基础容量是10,每次扩容是当前数组的1.5倍
- LinkList是双向链表,不需要调整容量,也不是线程安全的
Vector和ArrayList作为动态数组,内部元素是以数组形式顺序存储的,所以非常适合随机访问的场合,也就是根据索引查询元素,除了尾部插入和删除元素以外,其他性能会比较差,因为数组是连续开辟的内存,当我们从中间位置插入一个元素的时候,就需要移动所有后面的元素,腾出一个空位,就很造成性能消耗。
怎么把ArrayList变成线程安全的
- 使用Collections类的SynchronizedList方法将ArrayList包装成线程安全的List:
ArrayList<Integer> arrayList = new ArrayList<>();
List<Integer> synchronizedList = Collections.synchronizedList(arrayList);
- 使用CopyOnWriteArrayList替代ArrayList,它是一个线程安全的List实现:
ArrayList<Integer> arrayList = new ArrayList<>();
CopyOnWriteArrayList<Integer> integers = new CopyOnWriteArrayList<>(arrayList);
- 使用Vector代替ArrayList
为什么说ArrayList不是线程安全的,具体来说是哪里不安全?
在高并发添加数据下,ArrayList会暴露三个问题:
部分值为null(并不是我们赋值null进去)
索引越界
size和我们add的数量不符合
我们先查看源代码 ArrayList的add方法
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
ensureCapacityInternal()这个方法主要是判断数组elementData的大小是否满足,需不需要扩容。
elementData[size++] = e;是把元素添加进数组里面,然后把size++,可以只打size也就是索引。
- 部分值为null的情况
当线程1走到扩容的时候发现当前的size为9,而数组的容量为10,所以不用扩容,此时线程2也进来了,发现size为9,数组容量10,也不用扩容,这时线程1继续执行,将数组索引为9的位置放了一个值,此时还没有执行size++,线程2也来了,又把数组为9的位置设值了一次,然后线程1和线程2都将size++,于是size为11,size10就为null,
- 索引越界情况。
线程1走到扩容发现是当前size是9,而容量是10,所以不用扩容,这时线程2也发现不用扩容,于是线程1执行完size++之后线程2又开始执行,此时线程2插入的是size为10的地方,而数组容量只有10,所以就发生了索引越界
- size 与我们add的数量不符
这个在第一种部分值为null的情况下就已经体现了,add的数量和size不符。set的值被覆盖了。
Map
HashMap实现原理
hashmap实现了map接口,是非线程安全的,查找,插入和删除操作的平均时间复杂度为O(1),在大多数情况下性能是很出色的。
hashmap的底层结构
在java7中使用的是“数组+链表”的形式,链表是在发生散列冲突的键值对会用头插法添加到单链表中。
如多个键位映射到用一个槽位,他们会以链表的形式存储在同一个槽位上,因为链表的查询时间是O(n),所以冲突很严重的时候,一个索引上的链表非常长,就会导致查询效率非常低。
在java8的时候就做了优化 使用了红黑树
在java8中使用的是“数组+链表+红黑树”,发送散列冲突的键值对会用尾插法添加到单链表中,如果链表的长度大于8且散列表的容量大于64,就会将链表转换为红黑树,把查询的时间复杂度降到O(longn),以此来提高查询性能,但是在数量将少的时候,即数量小于6则会将红黑树转换为链表。
解决哈希冲突的方法有哪些?
我们在说hashmap的时候,说到发生哈希冲突之后,为了解决hash冲突,就使用了链表。
发生哈希冲突除了使用链表之外还有什么方法呢?
- 链接法:使用链表或其他数据结构来存储冲突的键值对,将他们链接在同一个哈希桶中。
- 开放寻址法:在哈希表中找到另一个可用的位置来存储冲突的键值对,而不是存储在链表中。常见的开放寻址方法包括线性探测,二次探测和双重散列。
- 再哈希法:当发生冲突时,使用另一个哈希函数再次计算键的哈希值,直到找到一个空槽来存储键值对。
- 哈希桶扩容:当哈希冲突过多时,可以动态扩大哈希桶的数量,重新分配键值对,以减少冲突的概率。【Redis使用的就是rehash扩容】
hashmap的低层原理
hashmap里面的每个元素都是通过键值对key,value形式存储的。
- 首先会通过key值来计算对应的哈希值,并把key,value,hash值,下一个元素的地址封装成一个Entry对象;
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
...
}
- 然后再通过让哈希值求模的形式来确定Entry对象在数组中的位置。
- 假设在数组下标为2的位置,那么首先会看下标为2的位置上是不是空。
- 如果为空,就直接将Entry对象放到2位置。
- 如果下标为2的位置不为空,就发生了哈希碰撞,这个时候就需要遍历这个哈希桶的链表,查看哈希值和key值是不是都相同,如果相同则覆盖原来的元素,如果不相同则在头结点最佳元素形成链表形式。
hashmap是如何存储数据的?
创建hashmap对象时,并不会创建低层数组,这是一种懒初始化机制,直到第一次put操作的时候才会通过resize()扩容操作数组。 - 创建hashmap的对象,并且调用put函数
HashMap<Object, Object> hm = new HashMap<>(); //这里没有创建低层2数组 在这里面只进行了赋值加载因子 0.75
hm.put("test","test"); // k-v 创建底层数组
在put里面,传入参数给putVal函数的时候,传递的就是已经根据key值计算好的hash值
可以看到在put里面才的putVal函数里面会判断这个hash表的长度,如果没有这个数组才会resize()。
2. put的具体逻辑
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.如果哈希表为空(也就是第一次put),调用resize()创建一个哈希表,并且使用变量n记录下哈希表的长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//2.计算哈希桶的位置(n - 1) & hash使用的是位运算,可以更快的计算出位置比hash%n位运算更快
//3.如果在对应的哈希桶中没有值
if ((p = tab[i = (n - 1) & hash]) == null)
//3.1 直接把键值对插入这个哈希桶中
tab[i] = newNode(hash, key, value, null);
else {
//4.如果桶中已经有这个元素了
Node<K,V> e; K k;
//4.1比较桶中的元素(第一个元素)的hash值相等,key相等,
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//4.2 将第一个元素赋值给e,用e来记录 覆盖hash值和key值都相同的这个元素
e = p;
//4.3 如果这个桶中的第一个键值对的key不相同(也就是第一个元素对不上),且类型是红黑树,则按照红黑树来插入
else if (p instanceof TreeNode)
//红黑树的方法中会遍历查看是否有相等的key值以此覆盖
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//
else {
//4.4 如果这个桶中的第一个键值对的key不相同(也就是第一个元素对不上),且类型是链表,则按照链表来顺序查询是否相等,如果没有相等的,直接插入最后一个位置。
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//检查哈希链表的长度是否达到阈值,如果是的话转换为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
hashmap的get()过程
获取对象时,我们会将key传给get,它调用hashCode计算hash从而得到bucket位置,并进行一步调用equals()方法确定键值对。不管是红黑树还是链表都是遍历查找。红黑树要快
hashmap一般用什么key?为啥String适合做key呢?
一般来说会使用String做可以。因为String对象时不可变的,一旦被创建就不能被修改,这确保了key的稳定性,如果key是可变的,肯呢个会导致hashCode和equals方法的不一致,进而影响HashMap的正确性。
hashmap的可以可以为null吗?
hashmap的key可以为null,当使用hash()方法来计算key的哈希值,当key为空时,直接使key的哈希值为0,不走key.hashCode方法。
hashmap会有什么问题?
我们知道hashmap多线程环境下是不安全的,在多线程环境下,扩容的时候可能会导致环形链表的出现,形成死循环,所以改成了尾插法。
在多线程同时put的时候,如果索引位置相同,也会出现前一个key被后一个key覆盖,从而导致元素丢失。
hashmap扩容机制
hashmap默认的负载因子是0.75,当元素数量超过当前容量与负载因子的乘积时会发生扩容。默认容量是16。
扩容分为两个步骤:
1.第一步是对哈希表长度扩展(2倍),新开辟了一个两倍的数组空间(链表或者红黑树的空间是随机的,也就是不一定连续)
2.第二步是将就哈希表中的数据放到新的哈希表中。
怎么移动数据到新的哈希表中呢,为什么hashmap的大小是2的n次方?
因为我们使用的是2次幂的扩展(长度为原来的2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。
如我们从16扩展为32时,具体的变化如下所示:
因此元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位上多1bit,因此新的index就会发生这样的变化:
因此我们在转移元素位置的时候,不需要计算hash,只需要看原来的hash值新增的那个bit是0还是1,是0就是没变,是1的话,就是"原索引+oldcap"。
这样的设计,既省去了重新计算hash值的时间,同时,新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
往hashmap存20个元素,会扩容几次?
初始容量是16
160.75 = 12
当插入低13个元素时,达到负载因子的限制,于是hashmap从16扩容到32
320.75 = 24 > 20
所以只用扩容一次。
HashTable
hashmap和hashtable的区别
hashmap线程不安全,所以效率高一些,并且可以有一个null的key值;
hashtable是线程安全的,内部都是经过了Synchronized修饰,不可以有null的key和value值,初始容量是11,每次扩容是2n+1.底层数据结构为数组+链表。
hashtable底层实现原理
hashtable的底层数据结构主要是数组加上链表,数组是主题,链表是解决hash冲突存在的。
hashtable是线程安全的,实现方式是hashtable的所有方法均采用Synchronized关键字,当一个线程访问同步方法,另一个线程也访问的时候,就会陷入阻塞或者轮询的状态。
hashtable怎么实现线程安全
因为它的put,get做成了同步方法,保证了Hashtable的线程安全性,每个操作数据的方法都进行同步控制之后,由此带来的问题任何一个时刻只能有一个线程可以操纵Hashtable,所以其效率比较低。
在Java中,可以使用synchronized关键字来标记一个方法或者代码块,当某个线程调用该对象的synchronized方法或者访问synchronized代码块时,这个线程便获得了该对象的锁,其他线程暂时无法访问这个方法,只有等待这个方法执行完毕或者代码块执行完毕,这个线程才会释放该对象的锁,其他线程才能执行这个方法或者代码块。
Set
Set集合有什么特点?如何实现key无重复的?
set集合特点:Set集合中的元素是唯一的,不会出现重复的元素。
set实现原理:Set集合通过内部的数据结构(如哈希表、红黑树等)来实现key的无重复。当向Set集合中插入元素时,会先根据元素的hashCode值来确定元素的存储位置,然后再通过equals方法来判断是否已经存在相同的元素,如果存在则不会再次插入,保证了元素的唯一性。
有序的Set是什么?记录插入顺序的集合是什么?
有序的 Set 是TreeSet和LinkedHashSet。TreeSet是基于红黑树实现,保证元素的自然顺序。LinkedHashSet是基于双重链表和哈希表的结合来实现元素的有序存储,保证元素添加的自然顺序
记录插入顺序的集合通常指的是LinkedHashSet,它不仅保证元素的唯一性,还可以保持元素的插入顺序。当需要在Set集合中记录元素的插入顺序时,可以选择使用LinkedHashSet来实现。
并发
创建线程的方式
1.继承Thread类
优点:编写简单,如果需要访问当前线程直接使用this,不用使用Thread.currentThread。
缺点:因为线程类已经继承Thread类,所以不能再继承其他的父类。
class MyThread extends Thread{
@Override
public void run() {
System.out.println("开始");
}
}
class Thread01{
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
}
}
2.实现Runnable接口
为了防止一个类继承了一个类就不能再继承其他类的情况,可以使用实现Runable接口。重写run接口,将Runnable对象作为参数传递给Thread类的构造器,创建Thread对象调用其start方法启动线程。
优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况.
缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。
class MyRunnable implements Runnable{
@Override
public void run() {
System.out.println("开始");
}
}
class Thread01{
public static void main(String[] args) {
Thread thread = new Thread(new MyRunnable());
thread.start();
}
}
3.实现Callable接口与FutureTask
java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常,要执行Callable任务,需将他包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。
优缺点与上面一样。
class MyCallable implements Callable<Integer> {
@Override
public Integer call() throws Exception {
//线程执行的代码,这里返回一个整型结果
return 1;
}
}
class Thread01{
public static void main(String[] args) {
MyCallable myCallable = new MyCallable();
FutureTask<Integer> integerFutureTask = new FutureTask<>(myCallable);
Thread thread = new Thread(integerFutureTask);
thread.start();
try {
Integer i = integerFutureTask.get(); //获取线程执行的结果
System.out.println(i);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
4.使用线程池(Executor框架)
从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。
缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。
优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。
class Task implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getId());
}
}
class Thread01{
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executor.submit(new Task()); //提交任务到线程池
}
//关闭线程池
executor.shutdown();
}
}
锁
死锁
在一个系统中,如果进程之间形成了一个循环依赖关系,那么就会发生死锁。
产生死锁的四个必要条件:
1.互斥:统一段时间只能有一个进程能获取这个资源。
2.占有等待:进程至少拥有一个资源,并且正在等待其他资源
3.不可抢占:资源不能被强制性的抢夺。
4.循环等待:存在一个等待的循环队列,每个进程都在等待下一个进程所持有的资源。
怎么破解死锁
破坏产生死锁的四个条件中的一个就可以预防死锁。
1.破坏互斥条件,也就是说允许多个线程使用资源,但这肯定是与我们的需求相违背的。
2.破坏占有等待:实行资源预先分配,在线程运行前一次性向系统申请他所需要的资源,满足不了就不让运行。可以利用银行家算法。
3.破坏不可抢占,当一个线程申请新的资源不能被立即满足的时候,它就必须释放全部的资源,之后再重新申请。
4.破坏循环等待。实行资源有序分配,把所有资源先进行分类、编号、分配,使线程在申请资源时不会形成环形等待。
当死锁发生的时候,我们可以使用kill进程,抢占资源和回滚等方式来解除死锁。
饿死
饿死也称饥饿。是指某个进程因无法获取所需资源而无法执行,一直处于等待状态的情况。
饿死与死锁的差别在于: 死锁是由于多个进程/线程互相竞争资源造成的,饿死则是某个进程/线程无法获取资源造成的。
处理这个情况,可以使用公平调度(先来先服务,时间片轮转),限制时间。
等待和阻塞
阻塞(Blocking):
阻塞通常指的是线程被暂停,无法继续执行,因为某些条件尚未满足,或者线程试图获取某个资源时资源不可用。
例如,在 I/O 操作中,当线程尝试读取文件、网络套接字或键盘输入时,如果数据还没有准备好,线程将被阻塞,直到数据准备好。
阻塞通常是由操作系统或编程语言提供的机制来管理的。
等待(Waiting):
等待通常指的是线程主动进入某种等待状态,等待某个条件变为真或等待另一个线程的通知,以便继续执行。
例如,在多线程编程中,线程可以调用等待机制,如 wait() 或 await(),以等待某个条件的满足或其他线程的信号。
等待是编程语言或库提供的机制,通常与线程间的协作和同步一起使用。
区别:
阻塞通常是由外部条件引起的,线程被迫暂停,而等待通常是线程自愿进入等待状态。
阻塞是由操作系统或底层系统资源管理的,而等待通常是在应用程序级别通过编程实现的。
阻塞通常指的是线程无法继续执行,而等待通常是为了协调线程之间的操作。
阻塞和等待都是多线程编程中常见的概念,用于确保线程之间的正确协作和同步,以避免竞争条件和数据不一致问题。根据具体的情况,选择适当的机制来管理线程的阻塞和等待是非常重要的。
线程池的工作原理
线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗。
线程池分为核心线程池,线程池最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果核心线程数满了,就会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,就会按照一些丢弃的策略进行处理。
线程池参数:
public ThreadPoolExecutor(int corePoolSize, // 线程池核心线程数量
int maximumPoolSize, // 线程池中最大可容纳的线程数量
long keepAliveTime, // 当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。
TimeUnit unit, // keepAliveTime的单位
BlockingQueue<Runnable> workQueue, // 等待队列
ThreadFactory threadFactory, //线程工程,可以用来给线程取名字等等。
RejectedExecutionHandler handler // 拒绝策略,丢弃策略)
线程池工作队列满了有那些拒绝策略?
常用的拒绝策略有四种预制的策略,除此之外,还可以通过实现RejectedExecutionHandler接口来自定义拒绝策略。
四种预置的拒绝策略:
CallerRunsPolicy,执行线程池的线程去运行这个被拒绝的任务,除非线程池被停止或者线程池的任务队列已有空缺。
AbortPolicy,直接抛出一个任务被线程池拒绝的异常。
DiscardPolicy,不做任何处理,直接拒绝该任务。
DiscardOldestPolicy,丢弃最老的任务,然后执行该任务。
public class ThreadTest01 {
public static void main(String[] args) {
BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(2); // 任务队列
ThreadFactory threadFactory = Executors.defaultThreadFactory(); // 线程工厂
RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy(); // 拒绝策略
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0, 1, 1, TimeUnit.MINUTES, workQueue, threadFactory, handler);
//提交任务到线程池
for (int i = 0; i < 2; i++) {
threadPoolExecutor.submit(() -> {
System.out.println("Task executed by " + Thread.currentThread().getName());
});
}
//关闭线程池
threadPoolExecutor.shutdown();
}
}
核心线程数可以设置为0吗?
是可以的,当核心线程数为0的时候,来了一个任务之后,会先将任务添加到任务队列,判断当前工作线程数是否为0,如果为0,则会创建一个非核心线程进行执行。
线程池种类有哪些?
1.SchedulerThreadPool:可以设置定期的执行任务,它支持定时或周期性执行任务,比如每隔10秒钟执行一次任务,可以通过这个实现类设置定期执行任务的策略。
2.FixedThreadPool:他的核心线程数和最大线程数是一样的,所以可以把它看作死固定线程数的线程池,他的特点是线程池中的线程数除了初始阶段从0开始增加后,之后的线程数量就是固定的,就算任务超出了线程数,线程池会把超出的放进任务队列,如果任务队列满了,也会执行淘汰机制,而不会创建新的线程。
3.CachedThreadPool:可以称作缓存线程池,它的特点在于线程数几乎可以无限增加(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置的时候还可以对线程进行回收,也就是说线程池的线程数量不是固定的而是动态的,在这个线程池的内部也有一个用于存储提交任务的队列,是SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
4.SingleThreadExecutor:它会使用唯一的线程去执行任务,原理和FixedThreadPool是一样的,只不过这里线程只有一个,如果线程在执行任务过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为他们是多线程并行执行的。
SingleThreadScheduledExecutor:它实际和SchedulerThreadPool线程池非常相似,只是它的内部只有一个线程。
线程池中shutdown(),shutdownNow()这两个方法有什么作用?
shutdown() 方法尝试平滑地关闭线程池,也就是说,它不再接受新的任务,但允许已经提交的任务完成执行。具体来说:
停止接受新任务:
shutdown() 会停止线程池接受新的任务提交。
允许当前任务完成:
已经提交给线程池的任务将继续执行直到完成。
任务队列中的任务也将被处理。
shutdownNow() 方法尝试立即关闭线程池,并尝试取消正在执行的任务。具体来说:
停止接受新任务:
与 shutdown() 相同,shutdownNow() 也会停止线程池接受新的任务提交。
尝试取消当前任务:
shutdownNow() 尝试取消所有正在执行的任务,并清空任务队列。
这个方法并不会等待当前正在执行的任务完成,而是尝试中断它们。
返回值:
shutdownNow() 返回一个 List,其中包含了尚未开始执行的任务以及被成功取消的任务。
这个列表不包括那些在 shutdownNow() 被调用之前已经开始执行且未能被取消的任务。
说明:它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用, interrupt() 方法是无法中断当前的线程的。所以,shutdownNow() 并不代表线程池就一定立即就能退出,它也可能必须要等待所有正在执行的任务都执行完成了才能退出。但是大多数时候是能立即退出的。
提交给线程池中的任务可以被撤回吗
可以的,当我们向线程池提交一个任务时,会得到一个Future对象。这个Future对象提供了集中方法来管理任务的执行,包括取消任务。
取消任务的主要方法是Future接口中的cancel方法,这个方法尝试取消执行的任务,参数mayInterruptIfRunning指示是否允许中断正在执行的任务。如果设置为true,则表示如果任务已经开始执行,那么允许中断任务;如果设置为false,任务已经开始执行则不会被中断。
public class ThreadTest01 {
public static void main(String[] args) {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Future submit = executorService.submit(new Thread());
try {
submit.get();
} catch (ExecutionException e) {
throw new RuntimeException(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}finally {
submit.cancel(true);
}
}
}
多线程打印奇偶数,怎么控制打印顺序
public class ThreadTest02 {
private static final Object lock = new Object();
private static int count = 1;
private static final int MAX_COUNT = 10;
public static void main(String[] args) {
Runnable printOdd = () -> {
synchronized (lock) {
while (count < MAX_COUNT) {
if (count % 2 != 0) {
System.out.println(Thread.currentThread().getName() + ":" + count++);
//唤醒线程
lock.notify();
} else {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
Runnable printEven = () -> {
synchronized (lock) {
while (count < MAX_COUNT) {
if (count % 2 == 0) {
System.out.println(Thread.currentThread().getName() + ":" + count++);
//唤醒线程
lock.notify();
} else {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
};
Thread oddThread = new Thread(printOdd, "oddThread");
Thread evenThread = new Thread(printEven, "printEven");
oddThread.start();
evenThread.start();
}
}
虚拟机
在了解jvm之前,我们先了解一下cpu和计算机内存的交互情况。
因为java虚拟机内存模型定义的访问操作与计算机及十分相似。
cpu和计算机内存的交互
在计算机中,cpu和内存的交互最为频繁,相比内存,磁盘读写太慢,内存相当于高速的缓冲区。
但是随着cpu的发展,内存的读写速度也远远赶不上cpu。因此cpu厂商在每颗cpu上加上了高速缓存,用于缓解这种情况。现在 cpu和内存的交互大致如下。
cpu上加入了高速缓存这样做解决了处理器和内存速度之间的问题,但是引来了新的问题 - 缓存一致性
在多核cpu中,每个处理器都有各自的高速缓存(L1,L2,L3),而主内存只有一个。
高速缓存中 L1的空间 < L2的空间 < L3 的空间。
cpu要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找,每个cpu有且只有一套自己的缓存。
如何保证多个处理器运算涉及到同一个内存区域时,多线程场景下会存在缓存一致性问题,那么运行时怎么保证数据一致性?
为了解决这个问题,各个处理器需要遵循一些协议保证一致性。【如MSI,MESI等】
内存屏障
cpu中,每个cpu又有多级缓存【上图所说的高速缓存】,一般分为L1,L2,L3,因为这些缓存的出现,提高了数据访问性能,避免每次都向内存索取,但是弊端也很明显,不能实时的和内存发生信息交换,也就是说在不同cpu执行的不同线程对同一个变量的缓存值会不同。
硬件层的内存屏障分为两种:Load Barrier和Store Barrier 即读屏障和写屏障。【内存屏障是硬件层的】
为什么需要内存屏障
简单来说,就是为了解决不同cpu执行不同线程对同一变量的缓存值不同。
用volatile可以解决上面的问题,不同硬件对内存屏障的实现方式不一样。java屏蔽掉这些差异,通过jvm生成内存屏障的指令。
对于屏障,在指令前插入读屏障,可以让高速缓存中的数据失效,强制从内存存取。
内存屏障的作用
cpu的指令肯呢个是无序的,它有两个比较重要的作用。
1.组织屏障两侧指令重排序
2.强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
java内存模型
Java内存模型(JMM)就起到一个内存屏障的作用,jmm控制java线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。 【java涉及管理内存的一个模型】
java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和内存中取出变量这样的底层细节。
java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这里的工作内存是JMM的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。
JMM定义了主内存和本地内存这两个概念。主内存是所有线程共享的区域,而本地内存是每个线程私有的内存区域,它是JMM的一个抽象概念,并不真实存在。本地内存涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化,在逻辑上存储了该线程以读/写共享变量的拷贝副本。因此,本地内存的存在是为了解释和优化多线程环境中的内存访问行为,确保线程间的数据一致性和性能优化
就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:
jmm模型如下:
其中堆内存中含有线程共享的 实例(对象)、静态变量 和 数组元素等。【堆类型和方法区都是共享的,但是方法群一般来说不用gc】
为什么方法区可以不用gc?
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。垃圾收集行为在这个区域是比较少出现的,其内存回收目标主要是针对常量池的回收和对类型的卸载。当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。
内存分配与回收策略
内存分配:
对象的内存分配,在大方向上说,就是在堆上分配,对象主要分配在新生代的End区上。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的。其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关参数化的设置。
回收策略
在java中,内存的自动分配和回收由java虚拟机(JVM)的垃圾回收器负责。Java的垃圾回收器采用了自适应的分代垃圾回收策略,主要包括新生代和老生代的回收策略。
垃圾回收机制:
垃圾回收主要关注Java堆
Java内存运行时区域中的程序计数器,虚拟机栈,本地方法栈随线程而生灭;栈中的栈帧随着方法的进入和退出而进行着入栈出栈,每一个栈帧分配多少内存基本上是在类结构确定下来就已知,因此,这几个区域内存分配和回收具有确定性,不用考虑回收问题,在方法结束或者线程结束之后,内存自然就回收了。
但是java堆不一样,一个接口中多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,要在程序运行期间才会知道需要创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
java堆中分为年轻代和老年代
JVM将java堆内存划分为了两个区域:年轻代和老年代,其中年轻代也被称为“新生代”,新生代中每次使用的空间不超过90%,主要用来存放新生的对象;而老年代里面存放的都是生命周期长的对象,对于一些较大的对象(即需要分配一块较大的连续内存空间),是直接存入老年代的,还有很多从新生代的Survivor区域中熬过来的对象。
新生代包括Eden区和两个Survivor区(通常是From和To / s0和s1),老年代包括年老代和永久代(1.8之后为元空间)。
新生代是用来存放新创建的对象的,其中Eden区是对象的出生地,大部分的新对象都会被分配到Eden区,当Eden区满之后,会触发垃圾回收,存活的对象会被转移到其中一块空闲Survivor区中,随后清空Eden区,并继续在此区域分配新对象。对着垃圾回收再次触发,Eden区域Survivor区中存活的对象会被转移至另一个空闲的Survivor区。可以将非存活对象清除掉,这样交替使用两块Survivor区,可以避免频繁地进行全局垃圾回收。
当Survivor区无法容纳活对象时,这些对象就会被转移到年老代中,年老代主要用来存放活时间较长的对象,这些对象可以在多次垃圾回收中保持不变。
而永久代(或元数据区)则是用来存放JVM运行时需要的类信息、常量池等元数据,这部分内存通常被限制在64MB左右,并且永久代的大小不会随着应用程序的运行而改变。在Java 8及之后的版本中,永久代已经被移除,取而代之的是元空间(Metaspace)。
总体来说,Eden区和Survivor区主要是用于新生代对象的分配和垃圾回收,而年老代则是用于存放生命周期较长的对象,永久代(或元空间)则是用于存放JVM运行时需要的元数据。
在JVM中,Java Heap可以被分为几个区域,包括年轻代、年老代和永久代。其中,年轻代包括一个Eden空间和两个Survivor空间(一般称为S0和S1),年老代和永久代是分别存放长时间存活的对象和Java类的区域。
在年轻代中,大部分对象都是短时间存活的,因此采用了分代收集的思想。当Eden区域满时,会触发Minor GC,回收Eden区域和S0/S1区域中的垃圾对象。存活的对象会被复制到另一个空间中,比如从Eden区域复制到S0区域。每个Survivor区域的大小一般为Eden区域大小的一半,用于存放Eden区域和另一个Survivor区域中的存活对象。因此,当一次Minor GC后,存活的对象会被复制到另一个Survivor区域中。
当一个对象经过多次复制后,如果仍然存活,它就会被移动到年老代。年老代的大小一般比年轻代大得多,因为它要存放长时间存活的对象。当年老代空间不足时,会触发Major GC或Full GC,回收整个堆内存中的垃圾对象。
永久代用于存放Java类的信息,比如类的名称、方法的名称和签名、字段的名称和类型等。由于这些信息在应用程序运行期间基本上不会发生变化,因此永久代的垃圾回收比较简单,可以采用“标记-清除”算法。然而,永久代的大小是固定的,如果应用程序加载了过多的类,就会导致永久代空间不足,出现“PermGen space”错误。
新生代如果只有一个Eden+一个Survivor可以吗?添加链接描述
判断哪些对象需要被回收
有两种方法:
- 引用计数法
给对象添加一引用计数器,被引用一次计数器值就加1;当引用失效时,计数器值就减1;计数器为0时,对象就是不可能再被使用的。优点是简单高效;缺点是无法解决对象之间相互循环引用的问题。 - 可达性分析法
通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,就证明此对象是不可用的。解决了上述循环引用的问题。
在java语言中,可作为GC Roots的对象包括下面几种:
1.虚拟机栈中引用的对象
2.方法区类静态属性引用的对象
简单的说就是我们在类中使用static声明的引用类型字段,例如:
Class Dog { private static Object tail; }
3.方法区中常量引用的对象
简单的说就是我们在类中使用final声明的引用类型字段,例如:
Class Dog { private final Object tail; }
4.本地方法栈中引用的对象
就是程序中native本地方法引用的对象。
可达性分析算法
不可达的对象将暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。
当对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,直接进行第二次标记。
如果这个对象被判定为有必要执行 finalize() 方法,那么这个对象将会放置在一个叫做 F-Queue 的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。
垃圾回收算法
- 标记-清除算法
最基础的收集算法是 标记-清除 算法,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
缺点:
效率问题,标记和清除两个过程的效率都不高;
空间问题,标记清楚之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。 - 复制算法
为了解决效率问题,一种为“复制”的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块的内存用完了,就将还存活着的对象赋值到另一块上面,然后再把已使用过的内空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。复制算法的执行过程如下图:
现在的商业虚拟机都采用这种算法来回收新生代,IBM 研究指出新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。
当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。HotSpot 虚拟机默认 Eden:Survivor = 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(其中一块Survivor不可用),只有 10% 的内存会被“浪费”。
当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。内存的分配担保也一样,如果另外一块 Survivor 空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。
- 标记-整理算法
复制算法在对象存活率较高时就要进行较多的复制操作,效率就会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能使用这种方法。
根据老年代的特点,提出了 标记-整理 算法,标记过程仍然与 标记-清除 算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4.分代回收算法
Minor GC和Full GC和Major GC
Minor GC触发条件:
当Eden区满时,触发Minor GC。
Major GC触发条件:
主要针对老年代进行回收,但不一定只回收老年代。
当老年代空间不足时,或者系统检测到年轻代对象晋升到老年代的速度过快可能会触发Major GC。
Full GC触发条件:
- System.gc()等命令触发:System.gc()、jmap -dump 等命令会触发 full gc
- Young GC 之前检查老年代:在要进行 Young GC 的时候,发现老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次 Young GC 后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,那就会触发 Full GC。
- Young GC 之后老年代空间不足:执行 Young GC 之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次 Full GC
- 老年代空间不足,老年代内存使用率过高,达到一定比例,也会触发 Full GC。
- 空间分配担保失败( Promotion Failure),新生代的 To 区放不下从 Eden 和 From 拷贝过来对象,或者新生代对象 GC 年龄到达阈值需要晋升这两种情况,老年代如果放下的话都会触发 Full GC。
- 方法区内存空间不足:如果方法区由永久代实现,永久代空间不足 Full GC。
Full GC是最昂贵的操作,因为它需要停止所有的工作线程(Stop The World),遍历整个堆内存来查找和回收不再使用的对象,因此应尽量减少Full GC的触发。
有什么不一样?
新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。
老年代 GC(Major GC/Full GC):指发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。
GC中的Stop the world(STW)
垃圾回收首先是要经过标记的,对象被标记后就会根据不同的区域采用不同的收集方法。垃圾回收并不会阻塞我们程序的线程,他是与当前程序并发执行的。所以问题就出在这里,当GC线程标记好了一个对象的时候,此时我们程序的线程又将该对象重新加入了“关系网”中,当执行二次标记的时候,该对象也没有重写finalize()方法,因此回收的时候就会回收这个不该回收的对象。 虚拟机的解决方法就是在一些特定指令位置设置一些“安全点”,当程序运行到这些“安全点”的时候就会暂停所有当前运行的线程(Stop The World 所以叫STW),暂停后再找到“GC Roots”进行关系的组建,进而执行标记和清除。
垃圾回收器
CMS收集器
cms收集器是一种以获取最短回收停顿时间为目标的收集器,用于老年代的垃圾收集。
注重用户体验,是第一个实现了让垃圾收集线程与用户线程同时工作的收集器。
运作过程:
CMS收集器是一种“标记-清除”算法实现的,它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
- 初始标记: 暂停所有的其他线程(STW),并记录下gc roots直接能引用的对象,速度很快。
- 并发标记: 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
- 重新标记:(SWT) 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。主要用到三色标记里的增量更新算法(见下面详解)做重新标记。
- 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫。这个阶段如果有新增对象会被标记为黑色不做任何处理(见下面三色标记算法详解)。
- 并发重置:重置本次GC过程中的标记数据。
优点:
并发收集
低停顿
缺点:
对CPU资源敏感(会和服务抢资源);
无法处理浮动垃圾(在并发标记和并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理了);
它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,
G1收集器
G1是一款面向服务器的垃圾收集器,主要是针对配备多颗处理器以及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
G1收集器的内存布局
G1将java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,他们都是可以不连续的的Region集合,即一个Region肯呢个之前是年轻代,如果Region进行了垃圾回收,之后肯呢个又会变成老年代,也就是说,Region的区域功能肯呢个会动态变化
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理。
G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。
在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够的GC开销。
Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
- G1收集器一次GC的运作过程大致分为以下几个步骤:
初始标记(initial mark,STW):
暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快;
并发标记(Concurrent Marking):
同CMS的并发标记;即并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行。因为用户程序继续运行,可能会有导致已经标记过的对象状态发生改变。
最终标记(Remark,STW):
同CMS的重新标记;即重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短。
筛选回收(Cleanup,STW):
筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,
(G1的Young GC和Mixed GC均采用标记赋值算法)
比如说:老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。
这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。
注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本。
- G1如何选择回收的Region?
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字Garbage-First的由来)。
比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。
这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。 - 特点
并行与并发:
G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短StopThe-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
分代收集:
虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
空间整合:
与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
可预测的停顿:
这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内完成垃圾收集。
备注:长度M毫秒,可以通过参数"-XX:MaxGCPauseMillis"指定。
可以由用户指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。
CMS和G1的区别?
- 使用的范围不一样
GMS收集器是老年代的收集器,可以配合新生代的收集器一起使用。
G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用。 - STW的时间
CMS收集器以最小的停顿时间为目标的收集器。
CMS收集器以最小的停顿时间为目标的收集器。 - 垃圾碎片
CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片。
G1收集器使用的是“标记-整理”算法,进行了空间整合,没有内存空间碎片。 - 垃圾回收的过程不一样
最后的第四阶段:CMS是并发清除,G1是塞选回收