Java 题库

Java

Java基础

语法

字符型常量和字符串常量的区别

char是 ASCII 值,字符串是地址值

标识符和关键字的区别

标识符是名字,关键字是特定的标识符

==和equals

对于基本数据类型来说,==比较的是值。对于引用数据类型来说,==比较的是对象的内存地址。

  • 类没有覆盖 equals()方法 :通过equals()比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是 Object类equals()方法。
  • 类覆盖了 equals()方法 :一般我们都覆盖 equals()方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。

    基本数据类型和包装类比较大小的问题

    当一个基础数据类型与封装类进行==、+、-、*、/运算时,会将封装类进行拆箱,对基础数据类型进行运算。
    equals在包装类中已经重写过,比较的是值的大小,但是也判断了是否为一个类: return (o instanceof Long) && (((Long) o).value == value);
    ==比较地址值,Integer有缓存,所以不用new也可能相等

    hashcode和equals

    1)hashCode()介绍:
    hashCode()的作用是获取哈希码,哈希码是用来确定索引位置。hashCode()定义在 JDK 的 Object 类中,任何类都包含它。此方法是本地方法。

2)为什么要有 hashCode?
快速确定一个值的索引,以hashmap为例,可以用hashcode值快速判断两个值是否是同一个

3)为什么重写 equals 时必须重写 hashCode 方法?
如果两个对象相等,则 hashcode 一定也是相同的。两个对象相等,对两个对象分别调用 equals 方法都返回 true。但是,两个对象有相同的 hashcode 值,它们也不一定是相等的 。因此,equals 方法被覆盖过,则 hashCode 方法也必须被覆盖。

hashCode()的默认行为是对堆上的对象产生独特值。如果没有重写 hashCode(),则该 class 的两个对象无论如何都不会相等(即使这两个对象指向相同的数据),是分别new出来的。

4)为什么两个对象有相同的 hashcode 值,它们也不一定是相等的?
哈希碰撞

String StringBuilder StringBuffer

对于三者使用的总结:

  1. 操作少量的数据: 适用 String
  2. 单线程操作字符串缓冲区下操作大量数据: 适用 StringBuilder
  3. 多线程操作字符串缓冲区下操作大量数据: 适用 StringBuffer

    阿里编约规定:for循环内使用 StingBuilder append方法拼接,不使用+号,因为+号每次循环都会new一个 StingBuilder,浪费资源

String为什么是不可变的

底层是一个char[] 的数组。由于value是private的,并且没有提供setValue等公共方法来修改这些值,所以在String类的外部无法修改String。也就是说一旦初始化就不能修改。此外,value变量是final的, 也就是说在String类内部,一旦这个值初始化了一直引用同一个对象

重载 vs 重写

  • 重载:同一个类中的方法,方法名相同,参数列表不同
  • 重写:子类重写父类方法。两同:方法名、参数列表 两小:返回值、异常类型 一大:访问权限大 “两同两小一大

深拷贝 VS 浅拷贝

  • 浅拷贝:对基本数据类型进行值传递,对引用数据类型进行引用传递般的拷贝,此为浅拷贝。
  • 深拷贝:对基本数据类型进行值传递,对引用数据类型,创建一个新的对象,并复制其内容,此为深拷贝。

自动装箱和自动拆箱

  • 装箱:将基本类型用它们对应的引用类型包装起来;
  • 拆箱:将包装类型转换为基本数据类型;

包装类就是把数据封装成对象,为了面向对象编程

面向对象三大特征

封装
把类封装成对象,内部属性对外部隐藏。外部无法访问内部属性,但是可以通过提供的方法访问。
继承
继承是指子类对父类的属性进行复用,使得子类具有父类相同的行为。通过使用继承,可以快速地创建新的类,可以提高代码的重用,程序的可维护性,节省大量创建新类的时间 ,提高我们的开发效率。
关于继承如下 3 点请记住:

  1. 子类拥有父类对象所有的属性和方法(包括私有属性和私有方法),但是父类中的私有属性和方法子类是无法访问,只是拥有
  2. 子类可以拥有自己属性和方法,即子类可以对父类进行扩展。
  3. 子类可以用自己的方式实现父类的方法。

多态
多态是同一个行为具有多个不同表现形式或形态的能力。具体表现形式为,父类引用指向子类对象。
多态的特点:

  • 对象类型和引用类型之间具有继承(类)/实现(接口)的关系;
  • 引用类型变量发出的方法调用的到底是哪个类中的方法,必须在程序运行期间才能确定;
  • 多态不能调用“只在子类存在但在父类不存在”的方法;
  • 如果子类重写了父类的方法,真正执行的是子类覆盖的方法,如果子类没有覆盖父类的方法,执行的是父类的方法。

IO

反射

可以通过反射访问private属性吗

可以,通过setAccessible(ture),getDeclaredField(),getDeclaredMethod()来获取

setAccessible是否破坏封装性

image.png --知乎回答

JVM

讲一讲双亲委派机制

当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类加载器去完成,比如我现在要new一个Person,这个Person是我们自定义的类,如果我们要加载它,就会先委派App ClassLoader,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的Class)时,子类加载器才会自行尝试加载
这样做的好处是避免核心API被篡改、避免类的重复加载

主动破坏双亲委派机制

JDBC
DriverManager是被根加载器加载的,但是他在加载时会尝试加载所有Driver的实现类,但是这些实现类基本都是第三方提供的,根据双亲委派原则,第三方的类不能被根加载器加载。

Jvm五大区

共享:方法区(元空间)、堆
私有:栈、本地方法栈、程序计数器
总结:栈管运行,堆管存储
方法区:对每个加载的类型(类class、接口、枚举、注解)、域信息(public)、方法信息

新生代和老年代

见原文中虚拟机堆的概念以及Eden年轻代介绍
image.png

类的加载过程

从类被加载到虚拟机内存中开始,到释放内存总共有7个步骤:加载,验证,准备,解析,初始化,使用,卸载。其中验证,准备,解析三个部分统称为连接。详细介绍可见原文

字符串常量池相关问题

字符串在java程序中被大量使用,为了避免每次都创建相同的字符串对象及内存分配,JVM内部对字符串对象的创建做了一定的优化
字符串常量池比较特殊,它的主要使用方法有两种:

  1. 直接使用双引号声明出来的 String 对象会直接存储在常量池中。创建时会判断是否存在,存在直接返回
  2. new的形式会创建对象,放在堆内存,和常量池不同
  3. 如果不是用双引号声明的 String 对象,使用 String 提供的 intern() 方法也有同样的效果。String.intern() 是一个 Native 方法,它的作用是:如果运行时常量池中已经包含一个等于此 String 对象内容的字符串,则返回常量池中该字符串的引用如果没有,JDK1.7 之前(不包含 1.7)的处理方式是在常量池中创建与此 String 内容相同的字符串,并返回常量池中创建的字符串的引用,JDK1.7 以及之后,字符串常量池被从方法区拿到了堆中,jvm 不会在常量池中创建该对象,而是将堆中这个对象的引用直接放到常量池中,减少不必要的内存开销
  4. 字符串常量池物理上在堆区,逻辑上还是说属于方法区

常量池介绍参考:https://www.cnblogs.com/syp172654682/p/8082625.html

⼀个对象从加载到JVM,再到被GC清除,都经历了什么过程

  1. ⽤户创建⼀个对象,JVM⾸先需要到⽅法区去找对象的类型信息。然后再创建对象。
  2. JVM要实例化⼀个对象,⾸先要在堆当中先创建⼀个对象。-> 半初始化状态
  3. 对象⾸先会分配在堆内存中新⽣代的Eden。然后经过⼀次Minor GC,对象如果存活,就会进⼊S 区。在后续的每次GC中,如果对象⼀直存活,就会在S区来回拷⻉,每移动⼀次,年龄加1。-> 多 ⼤年龄才会移⼊⽼年代? 年龄最⼤15, 超过⼀定年龄后,对象转⼊⽼年代。
  4. 当⽅法执⾏结束后,栈中的指针会先移除掉。
  5. 堆中的对象,经过Full GC,就会被标记为垃圾,然后被GC线程清理掉。

如何判断一个对象需要被干掉

  • 引⽤计数: 这种⽅式是给堆内存当中的每个对象记录⼀个引⽤个数。引⽤个数为0的就认为是垃圾。引⽤计数⽆法解决循环引⽤的问题。
  • 可达性分析: 这种⽅式是在内存中,从引⽤根对象向下⼀直找引⽤,找不到的对象就是垃圾。

如何宣告一个对象的真正死亡

判断一个对象的死亡至少需要两次标记

  1. 如果对象进行可达性分析之后没发现引用链,那它将会第一次标记并且进行一次筛选。判断的条件是决定这个对象是否有必要执行finalize()方法。如果对象有必要执行finalize()方法,则被放入F-Queue队列中。
  2. GC对F-Queue队列中的对象进行二次标记。如果对象在finalize()方法中重新与引用链上的任何一个对象建立了关联,那么二次标记时则会将它移出“即将回收”集合。如果此时对象还没成功逃脱,那么只能被回收了。

垃圾回收算法

标记清除

标记清除算法就是分为“标记”和“清除”两个阶段。标记出所有需要回收的对象,标记结束后统一回收。标记清除算法就是分为“标记”和“清除”两个阶段。标记出所有需要回收的对象,标记结束后统一回收

复制算法

为了解决效率问题,复制算法就出现了。它将可用内存按容量划分成两等分,每次只使用其中的一块。和survivor一样也是用from和to两个指针这样的玩法。fromPlace存满了,就把存活的对象copy到另一块toPlace上,然后交换指针的内容。这样就解决了碎片的问题。
这个算法的代价就是把内存缩水了,这样堆内存的使用效率就会变得十分低下了

标记整理

复制算法在对象存活率高的时候会有一定的效率问题,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存

分代收集算法

据对象存活周期的不同将内存划分为几块。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法,提高效率。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。

垃圾回收器

集合

Collection接口

Map接口

Hashmap底层

HashMap采用Entry数组来存储key-value对,每一个键值对组成了一个Entry实体,Entry类实际上是一个单向的链表结构,它具有Next指针,可以连接下一个Entry实体。只是在JDK1.8中,链表长度大于8的时候,链表会转成红黑树!

扩容

默认大小为16,扩容因子为0.75(大于这个值碰撞的概率会大大增加,是用松柏分布算出来的),在容量大于总大小的0.75倍的时候会扩容,新的大小为原本的两倍

长度为什么为2次幂

首先要先了解HashMap数组下标的计算方法。

  1. 数组下标是使用hash算法计算的,为了减少碰撞,就需要分布均匀,哈希范围值-20e-20e
  2. 但是这40e的空间显然是不可能存入内存的,所以需要别的解决办法

那肯定会想到%取余操作,但是为了实现高效,要使用位运算,所以需要使用位运算来构建取余操作
现在问题就变为怎么使用位运算来构建取余操作

  1. 在二进制计算中,有一个特性,除以2 ^ n,就是值的二进制数向右移动了n位,而余数就是的后n位
  2. 所以为了得到需要hash值的二进制的后n位,也就是余数,需要和n个1运算
  3. 这n个1的值就是2 ^ n-1

所以现在HashMap数组下标的计算方法是“ (n - 1) & hash”。(n 代表数组长度)。
总结一下,就是在计算存放下标时,既要减少碰撞且内存能放下,使用hash并取余,又要计算高效,使用位运算,最终构建了一种计算方式,就是(n - 1) & hash,这种计算方式要求长度n必须为2次幂

说说String中hashcode的实现?

String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。哈希计算公式可以计为s[0]31^(n-1) + s[1]31^(n-2) + … + s[n-1]

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;
        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

你一般用什么作为HashMap的key

此题可以组成如下连环炮来问

  • 健可以为Null值么?
  • 你一般用什么作为HashMap的key?
  • 我用可变类当HashMap的key有什么问题?
  • 如果让你实现一个自定义的class作为HashMap的key该如何实现?

参考链接:https://juejin.cn/post/6844903921190699022#heading-22

HashMap为什么不是线程安全的

在进行put操作的时候,会计算hash碰撞。如果线程A、B同时put,并且hash函数计算出的下标是相同的。如果此时A执行完判断,没有插入,CPU时间片用完。B得到时间片后正常插入。A由于已经进行完了碰撞判断,会直接进行put操作,导致后续A的put覆盖B的值,导致线程不安全。
++size,同理

HashMap的扩容过程

分为两步

  • 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
  • ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

重新Hash的原因:是因为Index的计算方法是使用位运算根据数组长度计算的(HashCode(Key) & (Length - 1)),元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。这个也是长度为二次幂的原因,方便位运算

谈一谈HashMap和ConcurrentHashMap的区别,ConcurrentHashMap是怎么实现线程安全的

ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 、synchronized 和 volatile 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。

并发

并发常见题

进程和线程

进程和线程

为什么使用多线程

  • 总体上,现在很多系统对并发量的要求越来越高,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
  • 从计算机层面来说,多线程主要是为了提高进程利用多核 CPU 的能力。

线程状态

image.png

并发出现问题的根源: 并发三要素

  • 可见性:CPU缓存。CPU缓存的值没有写到主存就被读取
  • 原子性:分时复用。转账问题
  • 有序性:重排序。

Java解决并发问题的方法

第一个维度:知识点

  • volatile、synchronized 和 final 三个关键字
  • Happens-Before 规则

    第二个维度:可见性,有序性,原子性

    原子性:可以使用synchronized,同一时间只有一个线程执行该代码
    可见性:当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
    有序性:可以通过volatile关键字来保证一定的“有序性”,JMM通过happens-before 保证有序性
    原子性、可见性、有序性都可以通过锁synchronized保证

实现线程安全的方法

  • 互斥同步(阻塞同步):synchronized
  • 非阻塞同步:解决线程阻塞和唤醒所带来的性能问题
    • CAS(Compare-and-Swap):硬件支持的原子性操作
    • 原子类:底层volatile的变量和unsafe的CAS
  • 无同步方案:不共享变量,使用私有变量
    • 使用局部变量
    • 使用线程本地储存——ThreadLocal 类

什么是上下文切换

线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息
有几种情况线程会从使用CPU的的状态中退出:

  • wait、sleep主动退出
  • 时间片用完
  • 中断

线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 上下文切换

死锁

死锁是什么

首先,资源是有限的,所以多个进程会竞争有限的资源。在竞争过程中,每个进程都在等待其他进程释放资源,这样没有进程抢先释放自己的资源,所有进程都会无限的等待下去,这种情况被称为死锁。

死锁产生条件

  • 互斥:资源只有两种状态,被占用和可用状态,这两种状态是互斥的
  • 占有并等待:一个进程应该占有一个资源,并等待另一个资源,但是这个资源是被其他进程占用的
  • 非抢占:资源不能被抢占,只能被主动释放
  • 循环等待:系统中一定有两个或者两个以上的进程组成一个循环,循环中的每个进程都在等待下一个进程释放的资源

四个条件必须同时满足才会发生死锁

预防死锁

  • 破坏占有并等待:一次性申请所有资源
  • 破坏非抢占:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待:按某一顺序申请资源

Sleep和Wait

  • 相同点:两者都可以暂停线程的执行。
  • 不同点:
    • wait()是Object类,sleep()是Thread类
    • 两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁 。
    • wait() 通常被用于线程间交互/通信,sleep() 通常被用于暂停执行。
    • wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒。[

](https://www.pdai.tech/md/java/thread/java-thread-x-thread-basic.html)

实现接口 VS 继承 Thread

实现接口会更好一些,因为:

  • Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
  • 类可能只要求可执行就行,继承整个 Thread 类开销过大。

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

new 一个 Thread,线程进入了新建状态。调用 start()方法,会启动一个线程并使线程进入了就绪状态,然后自动执行 run() 方法的内容。 但是,直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

synchronized 关键字

synchronized使用方式

  • 修饰实例方法。对象实例的锁
  • 修饰静态方法。相当于类锁
  • 修饰代码块相当于类锁
  • 尽量不要使用 synchronized(String a) 因为 synchronized锁需要是同一个对象,string很多情况是不同的对象,使用intern解决,但是会对效率有影响。参考:https://www.cnblogs.com/xrq730/p/6662232.html

不能用synchronized修饰构造器

没有实际的需要把构造器定义成同步的,因为它将会在构造的时候锁住该对象,直到所有的构造器完成它们的工作,这个构造的过程对其它线程来说,通常是不可访问的。

synchronized抛出异常后会释放锁

synchronized是非公平锁


1.6之后synchronized做了哪些优化

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。
偏向锁:适用于只有一个线程访问的同步场景
偏向锁是针对于一个线程而言的, 线程获得锁之后就不会再有解锁等操作了, 这样可以省略很多开销. 假如有两个线程来竞争该锁话, 那么偏向锁就失效了, 进而升级成轻量级锁了.
为什么要这样做呢? 因为经验表明, 其实大部分情况下, 都会是同一个线程进入同一块同步代码块的
轻量级锁:追求响应时间, 同步快执行速度非常快
当出现有两个线程来竞争锁的话, 那么偏向锁就失效了, 此时锁就会膨胀, 升级为轻量级锁.两个线程竞争锁时,未竞争到锁的线程会自旋循环获取。
重量级锁:追求响应时间, 同步快执行速度非常快

Synchronized本质上是通过什么保证线程安全的? 分三个方面回答:加锁和释放锁的原理,可重入原理,保证可见性原理。

加锁和释放锁的原理:synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,获取和释放对象监视器 monitor 。指令会让对象在执行,使其锁计数器加1或者减1每一个对象在同一时间只与一个monitor(锁)相关联而一个monitor在同一时间只能被一个线程获得。
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法。
保证可见性原理:如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。Synchronized的happens-before规则,即监视器锁规则:同一个监视器,先加锁,后解锁。

可重入原理

“可重入锁” 指的是自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

Synchronized在使用时有何注意事项?

  • 锁对象不能为空,因为锁的信息都保存在对象头里

  • 作用域不宜过大,影响程序执行的速度,控制范围过大,编写代码也容易出错

  • 避免死锁

  • 不能用synchronized修饰构造器

  • 不锁String


    Synchronized使得同时只有一个线程可以执行,性能比较差,有什么提升的方法?

  • 优化 synchronized 的使用范围,让临界区的代码在符合要求的情况下尽可能的小。

  • 使用其他类型的 lock(锁),synchronized 使用的锁经过 jdk 版本的升级,性能已经大幅提升了,但相对于更加轻量级的锁(如读写锁)还是偏重一点,所以可以选择更合适的锁。

我想更加灵活地控制锁的释放和获取(现在释放锁和获取锁的时机都被规定死了),怎么办?

可以根据需要实现一个 Lock 接口,这样锁的获取和释放就能完全被我们控制了

Synchronized和Lock的对比,和选择

synchronized的缺陷

  • 效率低:锁的释放情况少,只有代码执行完毕或者异常结束才会释放锁;试图获取锁的时候不能设定超时,不能中断一个正在使用锁的线程,相对而言,Lock可以中断和设置超时

  • 不够灵活:加锁和释放的时机单一,每个锁仅有一个单一的条件(某个对象),相对而言,读写锁更加灵活

  • 无法知道是否成功获得锁,相对而言,Lock可以拿到状态,如果成功获取锁,....,如果获取失败,.....

    Lock解决相应问题

    Lock类这里不做过多解释,主要看里面的4个方法:

  • lock(): 加锁

  • unlock(): 解锁

  • tryLock(): 尝试获取锁,返回一个boolean值

  • tryLock(long,TimeUtil): 尝试获取锁,可以设置超时

讲Synchronized的思路

  1. Synchronized的作用域
  2. Synchronized本质上是通过什么保证线程安全的
  3. Synchronized在使用时有何注意事项?
  4. synchronized的缺陷
  5. 针对缺陷1.6之后synchronized做了哪些优化

ReentrantLock

ReentrantLock和synchronized

  • sychronized是⼀个关键字,ReentrantLock是⼀个类
  • sychronized会⾃动的加锁与释放锁,ReentrantLock需要程序员⼿动加锁与释放锁
  • sychronized的底层是JVM层⾯的锁,ReentrantLock是API层⾯的锁
  • sychronized是⾮公平锁,ReentrantLock可以选择公平锁或⾮公平锁
  • sychronized锁的是对象,锁信息保存在对象头中,ReentrantLock通过代码中int类型的state标识来标识锁的状态
  • sychronized底层有⼀个锁升级的过程

ReentrantLock的核心是AQS,那么它怎么来实现的,继承吗? 说说其类内部结构关系。

ReentrantLock实现了Lock接口,ReentrantLock总共有三个内部类。
NonfairSync与FairSync类继承自Sync类,Sync类继承自AbstractQueuedSynchronizer抽象类
即通过继承,AQS对其服务支持

  • ReentrantLock是如何实现公平锁的?
  • ReentrantLock是如何实现非公平锁的?
  • ReentrantLock默认实现的是公平还是非公平锁?
  • 使用ReentrantLock实现公平和非公平锁的示例?

volatile 关键字

CPU缓存

CPU缓存出现为了解决CPU速度和内存速度不一致的问题,所以就会出现CPU缓存中的变量和内存中不一致的问题

JMM模型

线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致
image.png
要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
所以,volatile 关键字 除了防止 JVM 的指令重排 ,还有一个重要的作用就是保证变量的可见性。

volatile作用

防止重排序

经典的单例模式

public class Singleton {
    public static volatile Singleton singleton;
    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

实例化一个对象其实可以分为三个步骤:

  • 分配内存空间。
  • 初始化对象。
  • 将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,多线程环境下就可能将一个未初始化的对象引用暴露出来,一些类
原理
插入lock前缀CPU指令,相当于内存屏障。这个指令的作用就是告诉CPU和编译器,这条指令不能被重排序

实现可见性

内存不可见的原因:线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因
原理:lcok前缀指令

  • 修改volatile变量时会强制将修改后的值刷新的主内存中。
  • 修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从读取主内存中的值。

实际上底层也是由lock指令实现的
《深入理解Java虚拟机》

实现有序性

happens-before 规则中有一条是 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。

使用 volatile 必须具备的条件

  • 对变量的写操作不依赖于当前值。
  • 该变量没有包含在具有其他变量的不变式中。
  • 独立于程序内其他内容

说说 synchronized 关键字和 volatile 关键字的区别

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在!

  • volatile 轻量级实现,所以 volatile 性能比synchronized要好 。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块
  • volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

ThreadLocal

是什么

ThreadLocal是一个将在多线程中为每一个线程创建单独的变量副本的类就是让每个线程绑定自己的值, 避免因多线程操作共享变量而导致的数据不一致的情况。

实现原理

ThreadLocalMap是ThreadLocal的静态内部类,是一个定制化的HashMap,但是没有实现Map接口
每个Thread中都具备一个ThreadLocalMap,而ThreadLocalMap可以存储以ThreadLocal为 keyObject 对象为 value 的键值对。

ThreadLocal的内存泄露

ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,而 value 是强引用。系统GC的时候,key会被回收。这个时候如果线程一直不结束,就有可能发生内存泄露。ThreadLocalMap 实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。使用完 ThreadLocal方法后 最好手动调用remove()方法
官方文档:为了应对非常大和长时间的用途,哈希表使用弱引用的 key。

线程池

是什么,为什么用线程池

线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。
好处:

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

ThreadPoolExecutor

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致 OOM。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致 OOM。

ThreadPoolExecutor 3 个最重要的参数:

  • corePoolSize : 核心线程数定义了最小可以同时运行的线程数量。
  • maximumPoolSize : 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
  • workQueue: 当新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。

execute()方法和 submit()方法的区别是什么呢?

  1. execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
  2. submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功

原子类

是什么

这里 Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。原子类说简单点就是具有原子/原子操作特征的类。
并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic

JUC中的类

  • 基本类型
  • 数组类型
  • 引用类型
  • 对象的属性修改类型


AtomicInteger 类常用方法

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 类的原理

  • volatile保证线程的可见性,多线程并发时,一个线程修改数据,可以保证其它线程立马看到修改后的值
  • CAS 保证数据更新的原子性。

AQS

是什么

AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出大量应用广泛的同步器

原理

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
个人理解就是:AQS管理线程去使用、分配一个共享资源。而分配机制就是一套线程阻塞等待以及被唤醒时锁分配的机制,获取不到锁的线程在队列,获取到锁的线程使用资源。
具体说就是,AQS使用一个int成员变量来表示同步状态,通过内置的先进先出队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
[

](https://www.pdai.tech/md/java/thread/java-thread-x-lock-AbstractQueuedSynchronizer.html)

AQS 对资源的共享方式

AQS定义两种资源共享方式

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

ReentrantReadWriteLock 可以看成是组合式,因为ReentrantReadWriteLock也就是读写锁允许多个线程同时对某一资源进行读。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。

AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:

isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

AQS 组件总结

  • Semaphore(信号量)-允许多个线程同时访问:emaphore(信号量)可以指定多个线程同时访问某个资源。
  • CountDownLatch (倒计时器): 通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
  • CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier 默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await() 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。

计算机基础

网络

数据结构

操作系统

算法

MySQL

SQL

语句执行顺序

from---where--group by---having---select---order by

索引

索引失效情况

最左匹配原则,abc,直接用bc的时候
image.png
or关键字会让索引失效,可以用union代替

B+树

B+树能存多少数据

B+树的非叶子节点仅保存索引字段和指针,假设主键为bigint类型,InnoDB 的bigint占用8个字节,指针占用6个字节,8+6=14,所以我们可以得出,一个page能存放的指针个数为16k/(8+6)约等于1170每个指针对应第二层的行记录数
每个指针对应第二层的行记录数
再来说说一个page能存储多少条行记录,常规的互联网项目单条行记录大小约为1k,那么一个page能存储的行记录数为16k/1k=16
所以一个2层高的b+树能存储的行记录数大约为117016=18720
3层为1170
1170*16约等于2190w
[

](https://blog.csdn.net/qq_33709582/article/details/108260720)

Sping/SpringBoot框架相关

IOC

AOP

AOP通知

前置通知(Before):在目标方法被调用之前调用通知功能 方法开始前开始事务
后置通知(After):在目标方法完成之后调用通知,此时不会关心方法的输出是什么 关闭数据库连接、关闭文件
返回通知(After-returning):在目标方法成功执行之后调用通知 方法完成后提交事务
异常通知(After-throwing):在目标方法抛出异常后调用通知 方法异常回滚事务
环绕通知(Around):通知包裹了被通知的方法,在被通知的方法调用之前和之后执行自定义的行为 Transactional注解

  1. 连接点(Join point)
    连接点是在应用执行过程中能够插入切面的一个点。
  2. 切点(Pointcut)
    一个切面并不需要通知应用的所有连接点,切点有助于缩小切面所通知的连接点范围。如果说通知定义了切面的“什么”和“何时”的话,那么切点就定义了“何处”。因此,切点其实就是定义了需要执行在哪些连接点上执行通知。
  3. 切面(Aspect)
    切面是通知和切点的结合。通知和切点共同定义了切面的全部内容——它是什么,在何时和在何处完成其功能。
  4. 引入(Introduction)
    引入允许我们向现有的类添加新方法或属性。
  5. 织入(Weaving)
    织入是把切面应用到目标对象并创建新的代理对象的过程。切面在指定的连接点被织入到目标对象中。在目标对象的生命周期中有很多个点可以进行织入:

    JDK代理和Cglib代理

    Spring 框架中同时使用了两种动态代理 JDK Proxy 和 CGLib,当 Bean 实现了接口时,Spring 就会使用 JDK Proxy,在没有实现接口时就会使用 CGLib,我们也可以在配置中指定强制使用 CGLib

  • JDK Proxy 是 Java 语言自带的功能,无需通过加载第三方类实现;
  • JDK Proxy 是通过拦截器加反射的方式实现的;CGLib利用ASM开源包,对代理对象类的class文件加载进来,通过修改其字节码生成子类来实现。
  • JDK Proxy 只能代理实现接口的类;CGLib都可以

常用注解

分层作答,con,service,dao

SpringBoot之常用注解 - Nihaorz - 博客园

中间件

Redis

过期策略

定期删除策略

Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,默认每 100ms 进行一次过期扫描:

  1. 随机抽取 20 个 key
  2. 删除这 20 个key中过期的key
  3. 如果过期的 key 比例超过 1/4,就重复步骤 1,继续删除。
  4. 有扫描时间限制,25ms
  5. server.hz配置了serverCron任务的执行周期,默认是10,即CPU空闲时每秒执行十次。每次清理过期key的时间不能超过CPU时间的25%

    定时删除

    每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

    惰性删除

    只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

Redis的内存淘汰策略

Redis的内存淘汰策略是指在Redis的用于缓存的内存不足时,怎么处理需要新写入且需要申请额外空间的数据。

  • noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。