面向对象

静态和实例

Java 的类成员(成员变量、方法等)可以是静态的或实例的。使用关键字 static 修饰的类成员是静态的类成员,不使用关键字 static 修饰的类成员则是实例的类成员。

静态和实例的区别
外部调用
从外部调用静态的类成员时,可以通过类名调用,也可以通过对象名调用。从外部调用实例的类成员,则只能通过对象名调用。

例如对于字符串类型 String,方法 format 是静态的,可以通过 String.format 调用,而方法 length 是实例的,只能通过 str.length 调用,其中 str 是 String 类型的实例。

建议通过类名调用静态的类成员,因为通过类名调用静态的类成员是不需要创建对象的,而且可以提高代码的可读性。

内部访问
静态方法只能访问静态的类成员,不能访问实例的类成员。实例方法既可以访问实例的类成员,也可以访问静态的类成员。

为什么静态方法不能访问实例的类成员呢?因为实例的类成员是依赖于具体对象(实例)的,而静态方法不依赖于任何实例,因此不存在静态方法直接或间接地访问实例或实例的类成员的情况。

判断使用静态或实例
如何判断一个类成员应该被定义成静态的还是实例的呢?取决于类成员是否依赖于具体实例。如果一个类成员依赖于具体实例,则该类成员应该被定义成实例的类成员,否则就应该被定义成静态的类成员。

例如对于字符串类 String,考虑方法 format 和方法 length。

方法 format 的作用是创建格式化的字符串,该方法不依赖于任何 String 的实例,因此是静态方法(类成员)。

方法 length 的作用是获得字符串的长度,由于字符串的长度依赖于具体字符串,因此该方法依赖于 String 的实例,是实例方法(类成员)。

对于数学类 Math,所有的类成员都不依赖于具体的实例,因此都被定义成静态的类成员。

习题
1. 使用什么关键字可以将类成员声明为静态?

关键字 static。

  1. 从外部调用类成员时,对于静态的类成员和实例的类成员,分别通过什么方式调用?

从外部调用静态的类成员时,可以通过类名调用,也可以通过对象名调用。从外部调用实例的类成员,则只能通过对象名调用。

  1. 从内部访问类成员时,静态方法是否可以访问静态的类成员和实例的类成员?实例方法是否可以访问静态的类成员和实例的类成员?

静态方法只能访问静态的类成员,不能访问实例的类成员。实例方法既可以访问实例的类成员,也可以访问静态的类成员。

抽象类和接口

抽象类指抽象而没有具体实例的类。接口是一种与类相似的结构,在很多方面与抽象类相近。

抽象类
抽象类使用关键字 abstract 修饰。抽象类和常规类一样具有数据域、方法和构造方法,但是不能用 new 操作符创建实例。

抽象类可以包含抽象方法。抽象方法使用关键字 abstract 修饰,只有方法签名而没有实现,其实现由子类提供。抽象方法都是非静态的。包含抽象方法的类必须声明为抽象类。

非抽象类不能包含抽象方法。如果一个抽象父类的子类不能实现所有的抽象方法,则该子类也必须声明为抽象类。

包含抽象方法的类必须声明为抽象类,但是抽象类可以不包含抽象方法。

接口
接口使用关键字 interface 定义。接口只包含可见性为 public 的常量和抽象方法,不包含变量和具体方法。

和抽象类一样,接口不能用 new 操作符创建实例。

新版本的 JDK 关于接口的规则有以下变化。

从 Java 8 开始,接口方法可以由默认实现。

从 Java 9 开始,接口内允许定义私有方法。

一个类只能继承一个父类,但对接口允许多重继承。一个接口可以继承多个接口,这样的接口称为子接口。

抽象类和接口的区别
抽象类的变量没有限制,接口只包含常量,即接口的所有变量必须是 public static final。

抽象类包含构造方法,子类通过构造方法链调用构造方法,接口不包含构造方法。

抽象类的方法没有限制,接口的方法必须是 public abstract 的实例方法(注:新版本的 JDK 关于接口的规则有变化,见上文)。

一个类只能继承一个父类,但是可以实现多个接口。一个接口可以继承多个接口。

自定义比较方法
有两个接口可以实现对象之间的排序和比较大小。

Comparable 接口是排序接口。如果一个类实现了 Comparable 接口,则该类的对象可以排序。Comparable 接口包含一个抽象方法 compareTo,实现 Comparable 接口的类需要实现该方法,定义排序的依据。

Comparator 接口是比较器接口。如果一个类本身不支持排序(即没有实现 Comparable 接口),但是又需要对该类的对象排序,则可以通过实现 Comparator 接口的方式建立比较器。Comparator 接口包含两个抽象方法 compare 和 equals,其中 compare 方法是必须在实现类中实现的,而 equals 方法在任何类中默认已经实现。

如果需要对一个数组或列表中的多个对象进行排序,则可以将对象的类定义成实现 Comparable 接口,也可以在排序时定义 Comparator 比较器。

习题
1. 抽象类和接口是否可以被实例化?

抽象类和接口都不能通过 new 操作符创建实例,只能通过具体类创建实例。

  1. 包含抽象方法的类是否必须被声明成抽象类?抽象类是否必须包含抽象方法?

包含抽象方法的类必须被声明成抽象类,具体类不能包含抽象方法。抽象类可以不包含抽象方法,即使一个类中的所有方法都是具体方法,也可以将这个类声明成抽象类。

  1. 从继承的角度而言,抽象类和接口的区别是什么?

类的继承只能是单重继承,即一个类只能继承一个父类,但是对接口的继承允许多重继承,即一个类可以继承多个接口。一个接口也可以继承多个接口,这样的接口称为子接口。

  1. 从变量、构造方法和方法的角度,阐述抽象类和接口的区别。

抽象类和接口有以下区别。

抽象类的变量没有限制,接口只包含常量,即接口的所有变量必须是 public static final。
抽象类包含构造方法,子类通过构造方法链调用构造方法,接口不包含构造方法。
抽象类的方法没有限制,接口的方法必须是 public abstract 的实例方法
新版本的 JDK 关于接口的规则有所不同。从 Java 8 开始,接口方法可以由默认实现。从 Java 9 开始,接口内允许定义私有方法。

  1. 如何对一个数组中的多个对象按照不同的依据进行排序?

为了按照不同的依据进行排序,则需要使用不同的比较器,通过实现 Comparator 接口实现比较器,在调用 Arrays.sort 方法时将数组和比较器作为参数,即可将数组按照指定的比较器进行排序。

序列化和反序列化

把对象转换成字节序列的过程称为对象的序列化,把字节序列恢复成对象的过程称为对象的反序列化。

可序列化接口 Serializable
只有当一个类实现了 Serializable 接口时,这个类的实例才是可序列化的。

Serializable 接口是一个标识接口,用于标识一个对象是否可被序列化,该接口不包含任何数据域和方法。

如果试图对一个没有实现 Serializable 接口的类的实例进行序列化,会抛出 NotSerializableException 异常。

将一个对象序列化时,会将该对象的数据域进行序列化,不会对静态数据域进行序列化。

关键字 transient
如果一个对象的类实现了 Serializable 接口,但是包含一个不可序列化的数据域,则该对象不可序列化。为了使该对象可序列化,需要给不可序列化的数据域加上关键字 transient。

如果一个数据域可序列化,但是不想将这个数据域序列化,也可以给该数据域加上关键字 transient。

在序列化的过程中,加了关键字 transient 的数据域将被忽略。

习题
1. 序列化和反序列化的含义分别是什么?

把对象转换成字节序列的过程称为对象的序列化,把字节序列恢复成对象的过程称为对象的反序列化。

  1. Serializable 接口的作用是什么?这个接口包含哪些信息?

Serializable 接口的作用是标识一个对象是否可被序列化,只有当一个类实现了 Serializable 接口时,这个类的实例才是可序列化的。这个接口是标识接口,不包含任何数据域和方法。

  1. 关键字 transient 的作用是什么?

关键字 transient 的作用是指定数据域不被序列化。当一个数据域不能被序列化,或者不想对一个数据域序列化时,给这个数据域加上关键字 transient,在序列化的过程中将忽略这个数据域。

Java虚拟机

运行时的数据区域

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。这些区域有不同的用途。

程序计数器
程序计数器是一块较小的内存空间,可以看作当前线程所执行的字节码的行号指示器。字节码解释器工作时,通过改变程序计数器的值选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖程序计数器完成。

为了线程切换后能恢复到正确的执行位置,每个线程都需要有独立的程序计数器。由于每个线程的程序计数器是独立存储的,因此各线程之间的程序计数器互不影响,这类内存区域被称为线程私有的内存区域。

程序计数器是唯一不会出现 OutOfMemoryError 的内存区域。

Java 虚拟机栈
和程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期与线程相同。

虚拟机栈描述的是 Java 方法执行的内存模型,每个方法被执行的时候会创建一个栈帧,用于存储局部变量表、操作栈、动态链接、方法出口等信息。一个方法被调用直至执行完成的过程对应一个栈帧在虚拟机中从入栈到出栈的过程。

局部变量表存放编译器可知的各种基本数据类型、对象引用类型和返回地址类型。

Java 虚拟机栈会出现两种异常。

如果虚拟机栈不可以动态扩展,当线程请求的栈深度大于虚拟机所允许的深度时,将抛出 StackOverflowError 异常;

如果虚拟机栈可以动态扩展,当无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。

本地方法栈
本地方法栈和虚拟机栈的作用相似。区别在于,虚拟机栈为虚拟机执行 Java 方法服务,本地方法栈为虚拟机使用到的本地方法服务。有的虚拟机(如 HotSpot 虚拟机)把本地方法栈和虚拟机栈合二为一。

和虚拟机栈一样,本地方法栈也会出现 StackOverflowError 和 OutOfMemoryError 两种异常。

Java 堆
对于大多数应用而言,Java 堆是 Java 虚拟机管理的内存中最大的一块。Java 堆是被所有线程共享的内存区域,其目的是存放对象实例,几乎所有的对象实例都在堆中分配内存。

Java 堆是垃圾回收器管理的主要内存,因此也称为 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现代编译器基本都采用分代垃圾回收算法,所以 Java 堆还可以分成新生代和老年代,新生代又可以细分成 Eden 区、From Survivor 区、To Survivor 区等。细分成多个空间的目的是更好地回收内存或者更快地分配内存。

方法区
和 Java 堆一样,方法区也是被所有线程共享的内存区域。方法区用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

当方法区无法满足内存分配需求时,将抛出 OutOfMemoryError 异常。

JDK 1.8 将方法区彻底移除,取而代之的是元空间,元空间使用的是直接内存。

运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息,用于存放编译器生成的字面量和符号引用,这些信息将在类加载后存放到方法区的运行时常量池中。

运行时常量池也受到方法区内存的限制,当常量池无法再申请到内存时将抛出 OutOfMemoryError 异常。

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

本机直接内存的分配不受到 Java 堆大小的限制,但是直接内存仍然受到本机总内存地大小及处理器寻址空间的限制。如果各个内存区域的总和大于物理内存限制,就会导致动态扩展时出现 OutOfMemoryError 异常。

垃圾回收

垃圾回收,顾名思义就是释放垃圾占用的空间,从而提升程序性能,防止内存泄露。当一个对象不再被需要时,该对象就需要被回收并释放空间。

Java 内存运行时数据区域包括程序计数器、虚拟机栈、本地方法栈、堆等区域。其中,程序计数器、虚拟机栈和本地方法栈都是线程私有的,当线程结束时,这些区域的生命周期也结束了,因此不需要过多考虑回收的问题。而堆是虚拟机管理的内存中最大的一块,堆中的内存的分配和回收是动态的,垃圾回收主要关注的是堆空间。

调用垃圾回收器的方法
调用垃圾回收器的方法是 gc,该方法在 System 类和 Runtime 类中都存在。

在 Runtime 类中,方法 gc 是实例方法,方法 System.gc 是调用该方法的一种传统而便捷的方法。

在 System 类中,方法 gc 是静态方法,该方法会调用 Runtime 类中的 gc 方法。

其实,java.lang.System.gc 等价于 java.lang.Runtime.getRuntime.gc 的简写,都是调用垃圾回收器。

方法 gc 的作用是提示 Java 虚拟机进行垃圾回收,该方法由系统自动调用,不需要人为调用。该方法被调用之后,由 Java 虚拟机决定是立即回收还是延迟回收。

finalize 方法
与垃圾回收有关的另一个方法是 finalize 方法。该方法在 Object 类中被定义,在释放对象占用的内存之前会调用该方法。该方法的默认实现不做任何事,如果必要,子类应该重写该方法,一般建议在该方法中释放对象持有的资源。

判断对象是否可回收
垃圾回收器在对堆进行回收之前,首先需要确定哪些对象是可回收的。常用的算法有两种,引用计数算法和根搜索算法。

引用计数算法
引用计数算法给每个对象添加引用计数器,用于记录对象被引用的计数,引用计数为 0 的对象即为可回收的对象。

虽然引用计数算法的实现简单,判定效率也很高,但是引用计数算法无法解决对象之间循环引用的情况。如果多个对象之间存在循环引用,则这些对象的引用计数永远不为 0,无法被回收。因此 Java 语言没有使用引用计数算法。

根搜索算法
主流的商用程序语言都是使用根搜索算法判断对象是否可回收。根搜索算法的思路是,从若干被称为 GC Roots 的对象开始进行搜索,不能到达的对象即为可回收的对象。

在 Java 中,GC Roots 一般包含下面几种对象:

虚拟机栈中引用的对象;

本地方法栈中的本地方法引用的对象;

方法区中的类静态属性引用的对象;

方法区中的常量引用的对象。

引用的分类
引用计数算法和根搜索算法都需要通过判断引用的方式判断对象是否可回收。

在 JDK 1.2 之后,Java 将引用分成四种,按照引用强度从高到低的顺序依次是:强引用、软引用、弱引用、虚引用。

强引用是指在程序代码中普遍存在的引用。垃圾回收器永远不会回收被强引用关联的对象。

软引用描述还有用但并非必需的对象。只有在系统将要发生内存溢出异常时,被软引用关联的对象才会被回收。在 JDK 1.2 之后,提供了 SoftReference 类实现软引用。

弱引用描述非必需的对象,其强度低于软引用。被弱引用关联的对象只能存活到下一次垃圾回收发生之前,当垃圾回收器工作时,被弱引用关联的对象一定会被回收。在 JDK 1.2 之后,提供了 WeakReference 类实现弱引用。

虚引用是最弱的引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知。在 JDK 1.2 之后,提供了 PhantomReference 类实现虚引用。

垃圾回收算法
标记—清除算法
标记—清除算法是最基础的垃圾回收算法,后续的垃圾收集算法都是基于标记—清除算法进行改进而得到的。标记—清除算法分为“标记”和“清除”两个阶段,首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记—清除算法有两个主要缺点。一是效率问题,标记和清除的效率都不高,二是空间问题,标记清除之后会产生大量不连续的内存碎片,导致程序在之后的运行过程中无法为较大对象找到足够的连续内存。

复制算法
复制算法是将可用内存分成大小相等的两块,每次只使用其中的一块,当用完一块内存时,将还存活着的对象复制到另外一块内存,然后把已使用过的内存空间一次清理掉。

复制算法解决了效率问题。由于每次都是对整个半区进行内存回收,因此在内存分配时不需要考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。复制算法的优点是实现简单,运行高效,缺点是将内存缩小为了原来的一半,以及在对象存活率较高时复制操作的次数较多,导致效率降低。

标记—整理算法
标记—整理算法是根据老年代的特点提出的。标记过程与标记—清除算法一样,但后续步骤不是直接回收被标记的对象,而是让所有存活的对象都向一端移动,然后清除边界以外的内存。

分代收集算法
分代收集算法根据对象的存活周期不同将内存划分为多个区域,对每个区域选用不同的垃圾回收算法。

一般把 Java 堆分为新生代和老年代。在新生代中,大多数对象的生命周期都很短,因此选用复制算法。在老年代中,对象存活率高,因此选用标记—清除算法或标记—整理算法。

分配内存与回收策略
Java 堆可以分成新生代和老年代,新生代又可以细分成 Eden 区、From Survivor 区、To Survivor 区等。对于不同的对象,有相应的内存分配规则。

Minor GC 和 Full GC
Minor GC 指发生在新生代的垃圾回收操作。因为大多数对象的生命周期都很短,因此 Minor GC 会频繁执行,一般回收速度也比较快。

Full GC 也称 Major GC,指发生在老年代的垃圾回收操作。出现了 Full GC,经常会伴随至少一次的 Minor GC。老年代对象的存活时间长,因此 Full GC 很少执行,而且执行速度会比 Minor GC 慢很多。

对象优先在 Eden 区分配
大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。

大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。大对象对于虚拟机的内存分配而言是坏消息,经常出现大对象会导致内存还有不少空间时就提前触发垃圾回收以获取足够的连续空间分配给大对象。

将大对象直接在老年代中分配的目的是避免在 Eden 区和 Survivor 区之间出现大量内存复制。

长期存活的对象进入老年代
虚拟机采用分代收集的思想管理内存,因此需要识别每个对象应该放在新生代还是老年代。虚拟机给每个对象定义了年龄计数器,对象在 Eden 区出生之后,如果经过第一次 Minor GC 之后仍然存活,将进入 Survivor 区,同时对象年龄变为 1,对象在 Survivor 区每经过一次 Minor GC 且存活,年龄就增加 1,增加到一定阈值时则进入老年代(阈值默认为 15)。

动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到阈值才能进入老年代。如果在 Survivor 区中相同年龄的所有对象的空间总和大于 Survivor 区空间的一半,则年龄大于或等于该年龄的对象直接进入老年代。

空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的空间总和,如果这个条件成立,那么 Minor GC 可以确保是安全的。

只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。

多线程

进程和线程

程序是含有指令和数据的文件,是静态的代码,被存储在磁盘或其他的数据存储设备中。进程是程序的一次执行过程,线程是进程划分成的更小的运行单位。

进程和线程的联系和区别
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即为一个进程的创建、运行以及消亡的过程。

线程是比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程,多个线程共享进程的堆和方法区内存资源,每个线程都有自己的程序计数器、虚拟机栈和本地方法栈。由于线程共享进程的内存,因此系统产生一个线程或者在多个线程之间切换工作时的负担比进程小得多,线程也称为轻量级进程。

进程和线程最大的区别是,各进程是独立的,而各线程则不一定独立,因为同一进程中的多个线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护,进程则相反。

线程的状态
线程在运行的生命周期中的任何时刻只能是 6 种不同状态的其中一种。

初始状态(NEW):线程已经构建,尚未启动。

运行状态(RUNNABLE):包括就绪(READY)和运行中(RUNNING)两种状态,统称为运行状态。

阻塞状态(BLOCKED):线程被锁阻塞。

等待状态(WAITING):线程需要等待其他线程做出特定动作(通知或中断)。

超时等待状态(TIME_WAITING):不同于等待状态,超时等待状态可以在指定的时间自行返回。

终止状态(TERMINATED):当前线程已经执行完毕。

多线程的优点和可能存在的问题
线程也称为轻量级进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程,多个线程同时运行可以减少线程上下文切换的开销。多线程是开发高并发系统的基础,利用好多线程机制可以显著提高系统的并发能力和性能。

多线程并发编程并不总是能提高程序的执行效率和运行速度,而且可能存在一些问题,包括内存泄漏、上下文切换、死锁以及受限于硬件和软件的资源限制问题等。

  • 进程和线程的联系有哪些?

进程是程序的依次执行过程,线程是比进程更小的执行单位,一个进程在其执行的过程中可以产生多个线程,多个线程共享进程的堆和方法区内存资源。

  • 进程和线程的区别是什么?

进程和线程最大的区别是,各进程是独立的,而各线程则不一定独立,因为同一进程中的多个线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护,进程则相反。

  • 线程的状态有几种,分别是什么状态?

线程的状态有 6 种,分别是初始状态、运行状态、阻塞状态、等待状态、超时等待状态和终止状态。

  • 等待状态和超时等待状态的区别是什么?

和等待状态相比,超时等待状态增加了超时限制,当到达指定的超时时间之后,线程将会返回到运行状态。

  • 为什么需要使用多线程?

线程是程序执行的最小单位,多个线程同时运行可以减少线程上下文切换的开销。多线程是开发高并发系统的基础(即允许在同一时间段执行多个任务),利用好多线程机制可以显著提高系统的并发能力和性能。

  • 多线程可能存在哪些问题?

多线程可能存在的问题包括内存泄漏、上下文切换、死锁以及受限于硬件和软件的资源限制问题等。

关键字 synchronized 和 volatile

关键字 synchronized 和 volatile 是多线程中经常用到的两个关键字。

关键字 synchronized
关键字 synchronized 解决的是多个线程之间访问资源的同步性,该关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

关键字 synchronized 最主要的三种使用方式是:修饰实例方法、修饰静态方法、修饰代码块。

修饰实例方法:给当前对象实例加锁,进入同步代码之前需要获得当前对象实例的锁。

修饰静态方法:给当前类加锁,进入同步代码之前需要获得当前类的锁。

修饰代码块:指定加锁对象,给指定对象加锁,进入同步代码块之前需要获得指定对象的锁。

关键字 volatile
关键字 volatile 解决的是变量在多个线程之间的可见性,该关键字修饰的变量会直接在主内存中进行读写操作,保证了变量的可见性。

除了保证变量的可见性以外,关键字 volatile 还有一个作用是确保代码的执行顺序不变。为了提高执行程序时的性能,编译器和处理器会对指令进行重排序优化,因此代码的执行顺序和编写代码的顺序可能不一致。添加关键字 volatile 可以禁止指令进行重排序优化。

只有当一个变量满足以下两个条件时,才能使用关键字 volatile。

对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值。

该变量没有包含在具有其他变量的不变式中。

关键字 synchronized 和 volatile 的区别
关键字 volatile 是线程同步的轻量级实现,不需要加锁,因此性能优于关键字 synchronized。

关键字 synchronized 可以修饰方法和代码块,关键字 volatile 只能修饰变量。

关键字 synchronized 可能发生阻塞,关键字 volatile 不会发生阻塞。

关键字 synchronized 可以保证数据的可见性和原子性,关键字 volatile 只能保证数据的可见性,不能保证数据的原子性。

关键字 synchronized 解决的是多个线程之间访问资源的同步性,关键字 volatile 解决的是变量在多个线程之间的可见性。

  1. 关键字 synchronized 解决的是什么问题?

关键字 synchronized 解决的是多个线程之间访问资源的同步性。

  1. 关键字 synchronized 修饰实例方法、静态方法和代码块时,分别是什么含义?

修饰实例方法时,给当前对象实例加锁;修饰静态方法时,给当前类加锁;修饰代码块时,给指定对象加锁。进入同步代码/代码块之前需要获得当前对象/当前类/指定对象的锁。

  1. 关键字 volatile 有哪两个作用?

关键字 volatile 的作用是保证变量的可见性,以及防止指令进行重排序优化。

  1. 关键字 volatile 的使用需要满足什么条件?

使用关键字 volatile 的变量必须满足两个条件:一是对变量的写入操作不依赖变量的当前值,或者能确保只有单个线程更新变量的值,二是该变量没有包含在具有其他变量的不变式中。

多线程相关的方法

Thread 类的方法
run 和 start
方法 run 在 Runnable 接口中被定义,方法 start 在 Thread 类中被定义。

创建一个 Thread 类的实例,即为创建了一个处于初始状态的线程。对一个处于初始状态的线程调用方法 start,该线程被启动,进入运行状态。调用方法 start 之后,方法 run 会自动执行。

通过调用方法 start,执行方法 run,才是多线程工作。如果直接执行方法 run,则方法 run 会被当成一个主线程下的普通方法执行,而不会在某个线程中执行,因此不是多线程工作。

sleep
方法 sleep 在 Thread 类中被定义。该方法的作用是使当前线程暂停执行一段时间,让其他线程有机会继续执行,但是该方法不会释放锁。

方法 sleep 需要捕获 InterruptedException 异常。

join
方法 join 在 Thread 类中被定义。该方法的作用是阻塞调用该方法的线程,直到当前线程执行完毕之后,调用该方法的线程再继续执行。

方法 join 需要捕获 InterruptedException 异常。

yield
方法 yield 在 Thread 类中被定义。该方法的作用是暂停当前正在执行的线程对象,并执行其他线程。实际调用方法 yield 时无法保证一定能让其他线程执行,因为线程调度时可能再次选中原来的线程对象。

Object 类的方法
wait
方法 wait 在 Object 类中被定义。该方法必须在 synchronized 语句块内使用,作用是释放锁,让其他线程可以运行,当前线程进入等待池中。

notify 和 notifyAll
方法 notify 和 notifyAll 在 Object 类中被定义。

方法 notify 的作用是从等待池中移走任意一个等待当前对象的线程并放到锁池中,只有锁池中的线程可以获取锁。

方法 notifyAll 的作用是从等待池中移走全部等待当前对象的线程并放到锁池中,锁池中的这些线程将争夺锁。

中断线程
中断线程的方法是 interrupt,在 Thread 类中被定义。该方法不会中断一个正在运行的线程,只是改变中断标记。

当线程处于等待状态、超时等待状态或阻塞状态时,如果对线程调用方法 interrupt 将线程的中断标记设为 true,则中断标记会被清除,同时会抛出 InterruptedException 异常。可以通过 try-catch 块捕获该异常,即可终止线程。

当线程处于运行状态时,可以对线程调用方法 interrupt 将线程的中断标记设为 true,从而达到终止线程的目的,也可以添加一个 volatile 修饰的额外标记,当需要终止线程时,更改该标记的值即可。

不推荐使用方法 stop 和 destroy 终止线程。

方法 stop 会立即停止方法 run 中剩余的全部工作,并抛出 ThreadDeath 错误,导致清理性工作无法完成,另外方法 stop 会立即释放该线程的所有锁,导致对象状态不一致。

方法 destroy 只是抛出 NoSuchMethodError,没有做任何事情,因此无法终止线程。

习题
1. 启动线程时,为什么要通过调用方法 start 执行方法 run,而不能直接执行方法 run?

通过调用方法 start,执行方法 run,才是多线程工作。如果直接执行方法 run,则方法 run 会被当成一个主线程下的普通方法执行,而不会在某个线程中执行,因此不是多线程工作。

  1. 方法 sleep、join 和 yield 的区别有哪些?

方法 sleep 的作用是使当前线程暂停执行一段时间,让其他线程有机会继续执行;方法 join 的作用是阻塞调用该方法的线程,直到当前线程执行完毕之后,调用该方法的线程再继续执行;方法 yield 的作用是暂停当前正在执行的线程对象,并执行其他线程。

  1. 为什么方法 wait、notify 和 notifyAll 在 Object 类定义,而不是在 Thread 类定义?

以下是部分原因。

方法 wait、notify 和 notifyAll 不只是普通方法或同步工具,它们还是 Java 中两个线程之间的通信机制。对语言设计者而言, 如果不能通过 Java 关键字(例如 synchronized)实现通信机制,同时又要确保这个机制对每个对象可用, 那么 Object 类则是的合理的声明位置。
每个对象都可上锁,因此方法 wait 和 notify 在 Object 类而不是 Thread 类定义。
4. 终止线程应该使用什么方法?为什么不推荐使用方法 stop 和 destroy 终止线程?

终止线程应该使用方法 interrupt。方法 stop 会带来两个问题,一是清理性工作无法完成,二是会立即释放所有锁导致对象状态不一致,因此会造成不安全。方法 destroy 除了抛出 NoSuchMethodError 以外没有做任何事情,因此无法终止线程。

线程池

线程池是一种线程的使用模式。创建若干个可执行的线程放入一个池(容器)中,有任务需要处理时,会提交到线程池中的任务队列,处理完之后线程并不会被销毁,而是仍然在线程池中等待下一个任务。

线程池的好处
在开发过程中,合理地使用线程池可以带来 3 个好处。

降低资源消耗。重复利用线程池中已经创建的线程,可以避免频繁地创建和销毁线程,从而减少资源消耗。

提高响应速度。由于线程池中有已经创建的线程,因此当任务到达时,可以直接执行,不需要等待线程创建。

提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

线程池的创建
可以通过 ThreadPoolExecutor 类创建线程池。ThreadPoolExecutor 类有 4 个构造方法,其中最一般化的构造方法包含 7 个参数。

public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)
7 个参数的含义如下。

corePoolSize:核心线程数,定义了最少可以同时运行的线程数量,当有新的任务时就会创建一个线程执行任务,当线程池中的线程数量达到 corePoolSize 之后,到达的任务进入阻塞队列。

maximumPoolSize:最大线程数,定义了线程池中最多能创建的线程数量。

keepAliveTime:等待时间,当线程池中的线程数量大于 corePoolSize 时,如果一个线程的空闲时间达到 keepAliveTime 时则会终止,直到线程池中的线程数不超过 corePoolSize。

unit:参数 keepAliveTime 的单位。

workQueue:阻塞队列,用来存储等待执行的任务。

threadFactory:创建线程的工厂。

handler:当拒绝处理任务时的策略。

向线程池提交任务
可以通过方法 execute 向线程池提交任务。该方法被调用时,线程池会做如下操作。

如果正在运行的线程数量小于 corePoolSize,则创建核心线程运行这个任务。

如果正在运行的线程数量大于或等于 corePoolSize,则将这个任务放入阻塞队列。

如果阻塞队列满了,而且正在运行的线程数量小于 maximumPoolSize,则创建非核心线程运行这个任务。

如果阻塞队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,则线程池抛出 RejectExecutionException 异常。

上述操作中提到了两个概念,「核心线程」和「非核心线程」。核心线程和非核心线程的最大数目在创建线程池时被指定。核心线程和非核心线程的区别如下。

向线程池提交任务时,首先创建核心线程运行任务,直到核心线程数到达上限,然后将任务放入阻塞队列。

只有在核心线程数到达上限,且阻塞队列满的情况下,才会创建非核心线程运行任务。

关闭线程池
可以通过调用线程池的方法 shutdown 或 shutdownNow 关闭线程池。这两个方法的原理是遍历线程池中的工作线程,对每个工作线程调用 interrupt 方法中断线程,无法响应中断的任务可能永远无法终止。

方法 shutDown 和 shutDownNow 有以下区别。

方法 shutDown 将线程池的状态设置成 SHUTDOWN,正在执行的任务继续执行,没有执行的任务将中断。

方法 shutDownNow 将线程池的状态设置成 STOP,正在执行的任务被停止,没有执行的任务被返回。

习题
1. 什么是线程池?

线程池是一种线程的使用模式。创建若干个可执行的线程放入一个池(容器)中,有任务需要处理时,会提交到线程池中的任务队列,处理完之后线程并不会被销毁,而是仍然在线程池中等待下一个任务。

  1. 线程池的好处有哪些?
    线程池有以下 3 点好处。

降低资源消耗。重复利用线程池中已经创建的线程,可以避免频繁地创建和销毁线程,从而减少资源消耗。
提高响应速度。由于线程池中有已经创建的线程,因此当任务到达时,可以直接执行,不需要等待线程创建。
提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

  1. 向线程池提交任务的流程是什么?
    调用方法 execute 向线程池提交任务时,线程池会做如下操作。

如果正在运行的线程数量小于 corePoolSize,则创建核心线程运行这个任务。
如果正在运行的线程数量大于或等于 corePoolSize,则将这个任务放入阻塞队列。
如果阻塞队列满了,而且正在运行的线程数量小于 maximumPoolSize,则创建非核心线程运行这个任务。
如果阻塞队列满了,而且正在运行的线程数量大于或等于 maximumPoolSize,则线程池抛出 RejectExecutionException 异常。

  1. 如何关闭线程池?
    可以通过调用线程池的方法 shutdown 或 shutdownNow 关闭线程池。

Java Tips

Java

查看Java版本

  1. java -version
  2. #check the version of the primary java compiler
  3. javac -version

查看Java的安装路径

  1. update-alternatives --list java
  2. #or
  3. whereis java
  4. #or
  5. ls -l /usr/bin/java
  6. #or
  7. ls -l /etc/alternatives/java

查看Java的package list

  1. #list all installed packages
  2. sudo apt list --installed
  3. #grep package name is openjdk
  4. sudo apt list --installed | grep -i openjdk

Code snips

base64转string,string转base64

  1. public static String strConvertBase(String str) {
  2. if(null != str){
  3. Base64.Encoder encoder = Base64.getEncoder();
  4. return encoder.encodeToString(str.getBytes());
  5. }
  6. return null;
  7. }
  8. public static String baseConvertStr(String str) {
  9. if(null != str){
  10. Base64.Decoder decoder = Base64.getDecoder();
  11. try {
  12. return new String(decoder.decode(str.getBytes()), "GBK");
  13. } catch (UnsupportedEncodingException e) {
  14. return null;
  15. }
  16. }
  17. return null;
  18. }

https://phoenixnap.com/kb/check-java-version-linux
https://blog.csdn.net/pmdream/article/details/81383484