语言特点

说说 Java 语言的特点

  • 面向对象(封装、继承、多态 三个语言特性),用面向对象语言写出面向对象代码,还需要抽象能力
    • 封装
      • 对象封装数据和功能,对外屏蔽复杂性,代码高内聚的基础
      • 基于接口而非实现编程,低耦合的基础
    • 继承
      • 源于自然世界进化的直观映射,拉马克进化(获得性遗传)适用于程序世界
      • 为类提供层次结构,提供相似性约束、差异性表现
      • 单根继承性:方便垃圾回收和异常处理
    • 多态
      • 提供多样性的同时,又保持对象可互换,极大降低拓展的代价。
      • 依赖编译器的后期绑定,被调用的代码直到运行时才确定
  • Write once, run anywhere
  • 解释执行和编译执行并存

为什么只支持单根继承?

Advantage of singly rooted class hierarchy

  • 大部分场景不需要多重继承,而且 Java 提供了多实现来曲线救国
  • 无法确保所有对象都属于同一个基本类型,Java 可以保证所有类型的基类是 Object
  • 单根继承结构使得垃圾回收器的实现变得容易得多
  • 会出现菱形继承的歧义性,例如:对于 D 来说 A 的属性到底来自 B 还是 C

image.png

Callable 和 Runnable 的区别

参考:4.2. 实现 Runnable 接口和 Callable 接口的区别

Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Runnable 接口 不会返回结果或抛出 检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口 ,这样代码看起来会更加简洁。

补充问题:以及全部都用 Callable 会有什么问题?

  • 是否会有性能问题?JMH 可以测试一波
  • 增加没必要编程复杂度,有些函数确实没有返回值,硬要返回 null

内存区域

参见:Java内存区域

image.png
image.png

线程隔离:程序计数器

  • 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器
  • 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了

⚠️:程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。

线程隔离:虚拟机栈

描述的是 Java 方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈。

每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

Java 虚拟机栈会出现两种错误:StackOverFlowErrorOutOfMemoryError

  • StackOverFlowError: 若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError: Java 虚拟机栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。

image.png

线程隔离:本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一

线程共享:堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
image.png
堆事最容易出现 OutOfMemoryError 错误的地方,并且出现这种错误之后的表现形式还会有几种,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
  2. java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置)

线程共享:元空间

元空间使用的是直接内存,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来,不参与 GC。

空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小,
当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

关于运行时常量池:
JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

线程共享:直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

数据格式

Char 基本类型可以装下一个 utf8 字符吗?

参考阅读:程序员必备:彻底弄懂常见的7种中文字符编码

答案是:不一定。char 占用 2 字节,可表示的范围大小为 Unicode 0 ~ Unicode 2^16 - 1,最多可表示 3 字节(最大值 2^16 - 1)的 utf8 编码,但 utf8 编码中最长支持 6 字节(最大值 2^31 -1),例如我们喜闻乐见的 Emoj 表情,大都是 4 字节 utf8 编码,😊 说的正是在下 😯

这里不得不提一嘴,mysql 中 utf8 编码的 char 类型是固定 3 字节,后来为了支持 Emoj 表情,增加了 utf8mb4 编码,它的 char 类型是固定 4 字节。

又一个冷知识:其中 mb4 的意思(most bytes 4),最多 4 个字节。

image.png

LinkedList 和 ArrayList 的区别?

1.2.2. Arraylist 与 LinkedList 区别?

对比项 ArrayList LinkedList
线程安全
数据结构 Object 数组,连续内存 双向链表,配合 HashMap 可以实现 O(1) 级别的删除,例如 LRU 的经典实现
插入和删除 队首和队尾是 O(1)
中间部分是 O(n-i)
队首和队尾是 O(1)
中间部分是 O(n)
随机访问 支持高效的随机访问 相对低效
内存空间占用 空间浪费主要体现在 List 列表的结尾会预留一定的容量空间 每一个元素都需要消耗比 ArrayList 更多的空间
初始化容量 未指定容量的情况下,默认为 0,第一个元素插入的时候初始化为 10, \
扩容策略 当前已有元素总计数 * 1.5 \

并发编程

线程池

参见: Java 线程池学习总结 Java线程池实现原理及其在美团业务中的实践

Executor 框架是 Java 1.5 之后引入的,提供了:

  • 线程池管理
    • 核心线程数、最大线程数、空闲超时时间
  • 队列管理
  • 线程工厂
  • 饱和策略
    • 默认AbortPolicy :抛出 RejectedExecutionException来拒绝新任务的处理。
    • CallerRunsPolicy :调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
    • DiscardPolicy :不处理新任务,直接丢弃掉。
    • DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。

通过 Executors 可以可以创建:

类型 特点 缺点
SingleThreadPool 只有一个线程的线程池 允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM
FixedThreadPool corePoolSize 和 maximumPoolSize 都被设置为 nThreads,固定线程数 同 SingleThreadPool
CachedThreadPool corePoolSize 被设置为空(0),maximumPoolSize被设置为 Integer.MAX.VALUE,即它是无界。

线程空闲存活时间是 60s
允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM

如何基于业务场景设定线程池?
基于经验的:

  • CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1,比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
  • I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N

表示下图的 25 * Ncpu 存疑
image.png

真实业务场景中,还是要基于统计和监控,动态调整线程池大小,可以重点收集如下指标:

  1. QPS、TPS
  2. 机器 CPU、MEM 配置,以及使用率情况
  3. 当前的线程池配置

Synchronized

Java 并发进阶常见面试题总结 深度分析:锁升级过程和锁状态,看完这篇你就懂了!

可以修饰什么?

  1. 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁
  2. 修饰静态方法: 也就是给当前类加锁, 会作用于类的所有对象实例
  3. 修饰代码块 :指定加锁对象,对给定对象/类加锁

是否可以修饰构造方法?
不可以,构造方法本身就属于线程安全的,不存在同步的构造方法一说。

为什么是线程安全的?因为实例对象都还没创建,何谈资源竞争?

锁升级流程是什么?
锁可以单向升级:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

image.png

  • 在对象未计算 HashCode 的时候,会使用偏向锁;一旦发生竞争,可以优先获取到锁。
  • 轻量级
    • 锁中的 Lock Record 指向的是,当前锁对象目前 Mark Word 的拷贝,所以地址不同
    • 会尝试使用 CAS 操作,将当前对象的 Mark Word 更新为指向 Lock Record 的指针
    • 如果更新失败,则膨胀位重量级锁

image.png

Volatile 关键字

在 JDK1.2 之前,Java 的内存模型实现总是从主存(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存本地内存(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
image.png
要解决这个问题,就需要把变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

image.png
volatile 关键字解决了可见性问题,从如下两个角度

  • 防止 JVM 的指令重排,防止其他线程读取到未完成初始化的变量
  • 通过读写主内存,来解决同步延迟的问题

⚠️:无差别使用 Volatile 的话,会造成读取性能的降低,毕竟 CPU Cache 远比内存快得多,大概速率相差 100 倍。

计算机基础

内核态与用户态,如何切换?

怎样去理解Linux用户态和内核态? Linux 用户态切换到内核态的 3 种方式

内核是什么:一种特殊的软件程序,控制计算机的硬件资源,例如协调CPU资源,分配内存资源,并且提供稳定的环境供应用程序运行。
用户态:上层用户程序,依赖内核态资源

内核态权限远大于用户态,可以直接管控系统资源。
image.png
用户态到内核态怎样切换?

  1. 系统调用:这个上面已经讲解过了,在我公众号之前的文章也有讲解过。其实系统调用本身就是中断,但是软件中断,跟硬中断不同。
  2. 异常:当 CPU 在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
  3. 中断:当外围设备完成用户请求的操作后,会向 CPU 发出相应的中断信号,这时 CPU 会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等