1. String
-
String 的内部是由 char 数组构成
private final char value[];
-
构造方法有四种:
String(String original); String(char value[]) th); String(StringBuffer buffer); String(StringBuilder builder);
-
equals
和compareTo
String 重写了
equals
方法:支持传入 Object 类型,不是 String 类型返回 false
1. == 方法比较引用地址是否相同 2. 变量比较每一个 char,不同则返回false
compareTo
方法比较所有的字节并返回 int。 -
String 为什么使用
final
修饰- 为了安全:如果字符串是可以被修改的,在调用操作系统指令之前发生修改,有可能导致系统崩溃。
- 高效:可以使用 java 常量池来缓存数据。
-
String 和 JVM
String 常见的创建方式有两种,new String() 的方式和直接赋值的方式,直接赋值的方式会先去字符串常量池中查找是否已经有此值,如果有则把引用地址直接指向此值,否则会先在常量池中创建,然后再把引用指向此值;而 new String() 的方式一定会先在堆上创建一个字符串对象。
String s1 = new String("Java"); String s2 = s1.intern();// 放入常量池 String s3 = "Java"; System.out.println(s1 == s2); // false System.out.println(s2 == s3); // true
-
String 的长度限制
-
编译时长度限制
直接使用字符串文字定义String时,将字符串存储在常量池中。常量池中的每个数据项也有自己的类型。 Java的 UTF-8 编码Unicode 字符串在常量池中用 CONSTANT_Utf8类型表示,长度是 u2。u2 是一个无符号的16位整数, Java类文件使用UTF-8格式的变体来存储字符,空值占 2 个字节,所以长度是 2^16 -2 = 65534。
-
内存中长度限制
因为 String 在内存中是用用 char[] 来存储数据的,字符串长度类型是 int 类型,所以 char[] 的长度最大值为 int 的最大值,约4G。
-
2. HashMap
2.1 HashMap 添加元素流程
hashmap添加元素流程如下:
1、判断hash表是否为空,空则扩容。
2、根据key的hash值取模得到下标index。
3、判断tabe[index]是否为空,空则直接插入。否则判断key是否相等,可以相等直接赋值。
4、key不相等时,判断table[index]是否为红黑树,是就直接插入。
5、否则判断链表长度是否大于8,大于8转换成红黑树后插入,否则直接插入。
2.2 HashMap扩容
JDK 1.8 在扩容时并没有像 JDK 1.7 那样,重新计算每个元素的哈希值,而是通过高位运算(e.hash & oldCap)来确定元素是否需要移动,比如 key1 的信息如下:
key1.hash = 10 0000 1010
oldCap = 16 0001 0000
使用 e.hash & oldCap 得到的结果,高一位为 0,当结果为 0 时表示元素在扩容时位置不会发生任何变化,而 key 2 信息如下:
key2.hash = 10 0001 0001
oldCap = 16 0001 0000
这时候得到的结果,高一位为 1,当结果为 1 时,表示元素在扩容时位置发生了变化,新的下标位置等于原下标位置 + 原数组长度,如下图所示:
2.3 HashMap的加载因子为什么是0.75?
假如加载因子是 0.5,HashMap 的初始化容量是 16,那么当 HashMap 中有 16*0.5=8 个元素时,HashMap 就会进行扩容。
那加载因子为什么是 0.75 而不是 0.5 或者 1.0 呢?
这其实是出于容量和性能之间平衡的结果:
- 当加载因子设置比较大的时候,扩容的门槛就被提高了,扩容发生的频率比较低,占用的空间会比较小,但此时发生 Hash 冲突的几率就会提升,因此需要更复杂的数据结构来存储元素,这样对元素的操作时间就会增加,运行效率也会因此降低;
- 而当加载因子值比较小的时候,扩容的门槛会比较低,因此会占用更多的空间,此时元素的存储就比较稀疏,发生哈希冲突的可能性就比较小,因此操作性能会比较高。
所以综合了以上情况就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。
3. 线程的状态有哪些?它是如何工作的?
3.1 线程的状态和运行过程
线程的状态在 JDK 1.5 之后以枚举的方式被定义在 Thread 的源码中,它总共包含以下 6 个状态:
- NEW:新建状态,线程被创建出来,但尚未启动时的线程状态;
- RUNNABLE:就绪状态,表示可以运行的线程状态,它可能正在运行,或者是在排队等待操作系统给它分配 CPU 资源;
- BLOCKED:阻塞等待锁的线程状态,表示处于阻塞状态的线程正在等待监视器锁,比如等待执行 synchronized 代码块或者使用 synchronized 标记的方法;
- WAITING:等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作,比如,一个线程调用了 Object.wait() 方法,那它就在等待另一个线程调用 Object.notify() 或 Object.notifyAll() 方法;
- TIMED_WAITING:计时等待状态,和等待状态(WAITING)类似,它只是多了超时时间,比如调用了有超时时间设置的方法 Object.wait(long timeout) 和 Thread.join(long timeout) 等这些方法时,它才会进入此状态;
- TERMINATED:终止状态,表示线程已经执行完成。
线程状态的源代码如下:
public enum State {
/**
* 新建状态,线程被创建出来,但尚未启动时的线程状态
*/
NEW,
/**
* 就绪状态,表示可以运行的线程状态,但它在排队等待来自操作系统的 CPU 资源
*/
RUNNABLE,
/**
* 阻塞等待锁的线程状态,表示正在处于阻塞状态的线程
* 正在等待监视器锁,比如等待执行 synchronized 代码块或者
* 使用 synchronized 标记的方法
*/
BLOCKED,
/**
* 等待状态,一个处于等待状态的线程正在等待另一个线程执行某个特定的动作。
* 例如,一个线程调用了 Object.wait() 它在等待另一个线程调用
* Object.notify() 或 Object.notifyAll()
*/
WAITING,
/**
* 计时等待状态,和等待状态 (WAITING) 类似,只是多了超时时间,比如
* 调用了有超时时间设置的方法 Object.wait(long timeout) 和
* Thread.join(long timeout) 就会进入此状态
*/
TIMED_WAITING,
/**
* 终止状态,表示线程已经执行完成
*/
}
线程的工作模式是,首先先要创建线程并指定线程需要执行的业务方法,然后再调用线程的 start() 方法,此时线程就从 NEW(新建)状态变成了 RUNNABLE(就绪)状态,此时线程会判断要执行的方法中有没有 synchronized 同步代码块,如果有并且其他线程也在使用此锁,那么线程就会变为 BLOCKED(阻塞等待)状态,当其他线程使用完此锁之后,线程会继续执行剩余的方法。
当遇到 Object.wait() 或 Thread.join() 方法时,线程会变为 WAITING(等待状态)状态,如果是带了超时时间的等待方法,那么线程会进入 TIMED_WAITING(计时等待)状态,当有其他线程执行了 notify() 或 notifyAll() 方法之后,线程被唤醒继续执行剩余的业务方法,直到方法执行完成为止,此时整个线程的流程就执行完了,执行流程如下图所示:
3.2 BLOCKED 和 WAITING 的区别
虽然 BLOCKED 和 WAITING 都有等待的含义,但二者有着本质的区别,首先它们状态形成的调用方法不同,其次 BLOCKED 可以理解为当前线程还处于活跃状态,只是在阻塞等待其他线程使用完某个锁资源;而 WAITING 则是因为自身调用了 Object.wait() 或着是 Thread.join() 又或者是 LockSupport.park() 而进入等待状态,只能等待其他线程执行某个特定的动作才能被继续唤醒,比如当线程因为调用了 Object.wait() 而进入 WAITING 状态之后,则需要等待另一个线程执行 Object.notify() 或 Object.notifyAll() 才能被唤醒。
3.3 start() 和 run() 的区别
首先从 Thread 源码来看,start() 方法属于 Thread 自身的方法,并且使用了 synchronized 来保证线程安全,源码如下:
public synchronized void start() {
// 状态验证,不等于 NEW 的状态会抛出异常
if (threadStatus != 0)
throw new IllegalThreadStateException();
// 通知线程组,此线程即将启动
group.add(this);
boolean started = false;
try {
start0();
started = true;
} finally {
try {
if (!started) {
group.threadStartFailed(this);
}
} catch (Throwable ignore) {
// 不处理任何异常,如果 start0 抛出异常,则它将被传递到调用堆栈上
}
}
}
run() 方法为 Runnable 的抽象方法,必须由调用类重写此方法,重写的 run() 方法其实就是此线程要执行的业务方法,源码如下:
public class Thread implements Runnable {
// 忽略其他方法......
private Runnable target;
@Override
public void run() {
if (target != null) {
target.run();
}
}
}
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
从执行的效果来说,start() 方法可以开启多线程,让线程从 NEW 状态转换成 RUNNABLE 状态,而 run() 方法只是一个普通的方法。
其次,它们可调用的次数不同,start() 方法不能被多次调用,否则会抛出 java.lang.IllegalStateException;而 run() 方法可以进行多次调用,因为它只是一个普通的方法而已。
3.4 线程优先级
在 Thread 源码中和线程优先级相关的属性有 3 个:
// 线程可以拥有的最小优先级
public final static int MIN_PRIORITY = 1;
// 线程默认优先级
public final static int NORM_PRIORITY = 5;
// 线程可以拥有的最大优先级
public final static int MAX_PRIORITY = 10
线程的优先级可以理解为线程抢占 CPU 时间片的概率,优先级越高的线程优先执行的概率就越大,但并不能保证优先级高的线程一定先执行。
在程序中我们可以通过 Thread.setPriority() 来设置优先级。
3.5 线程的 join 方法
在一个线程中调用 other.join() ,这时候当前线程会让出执行权给 other 线程,直到 other 线程执行完或者过了超时时间之后再继续执行当前线程。join() 源码如下:
public final synchronized void join(long millis)
throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
// 超时时间不能小于 0
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
// 等于 0 表示无限等待,直到线程执行完为之
if (millis == 0) {
// 判断子线程 (其他线程) 为活跃线程,则一直等待
while (isAlive()) {
wait(0);
}
} else {
// 循环判断
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
从源码中可以看出 join() 方法底层还是通过 wait() 方法来实现的。
例如,在未使用 join() 时,代码如下:
public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
for (int i = 1; i < 6; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("子线程睡眠:" + i + "秒。");
}
});
thread.start(); // 开启线程
// 主线程执行
for (int i = 1; i < 4; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("主线程睡眠:" + i + "秒。");
}
}
}
程序执行结果为:
主线程睡眠:1秒。
子线程睡眠:1秒。
主线程睡眠:2秒。
子线程睡眠:2秒。
主线程睡眠:3秒。
子线程睡眠:3秒。
子线程睡眠:4秒。
子线程睡眠:5秒。
从结果可以看出,在未使用 join() 时主子线程会交替执行。
然后我们再把 join() 方法加入到代码中,代码如下:
public class ThreadExample {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
for (int i = 1; i < 6; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("子线程睡眠:" + i + "秒。");
}
});
thread.start(); // 开启线程
thread.join(2000); // 等待子线程先执行 2 秒钟
// 主线程执行
for (int i = 1; i < 4; i++) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("主线程睡眠:" + i + "秒。");
}
}
}
程序执行结果为:
子线程睡眠:1秒。
子线程睡眠:2秒。
主线程睡眠:1秒。
// thread.join(2000); 等待 2 秒之后,主线程和子线程再交替执行
子线程睡眠:3秒。
主线程睡眠:2秒。
子线程睡眠:4秒。
子线程睡眠:5秒。
主线程睡眠:3秒。
从执行结果可以看出,添加 join() 方法之后,主线程会先等子线程执行 2 秒之后才继续执行。
3.6 线程的 yield 方法
看 Thread 的源码可以知道 yield() 为本地方法,也就是说 yield() 是由 C 或 C++ 实现的,源码如下:
public static native void yield();
yield() 方法表示给线程调度器一个当前线程愿意出让 CPU 使用权的暗示,但是线程调度器可能会忽略这个暗示。
比如我们执行这段包含了 yield() 方法的代码,如下所示:
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println("线程:" +
Thread.currentThread().getName() + " I:" + i);
if (i == 5) {
Thread.yield();
}
}
}
};
Thread t1 = new Thread(runnable, "T1");
Thread t2 = new Thread(runnable, "T2");
t1.start();
t2.start();
}
当我们把这段代码执行多次之后会发现,每次执行的结果都不相同,这是因为 yield() 执行非常不稳定,线程调度器不一定会采纳 yield() 出让 CPU 使用权的建议,从而导致了这样的结果。
4. 线程池
4.1 线程池核心参数
第 1 个参数:corePoolSize 表示线程池的常驻核心线程数。如果设置为 0,则表示在没有任何任务时,销毁线程池;如果大于 0,即使没有任务时也会保证线程池的线程数量等于此值。但需要注意,此值如果设置的比较小,则会频繁的创建和销毁线程(创建和销毁的原因会在本课时的下半部分讲到);如果设置的比较大,则会浪费系统资源,所以开发者需要根据自己的实际业务来调整此值。
第 2 个参数:maximumPoolSize 表示线程池在任务最多时,最大可以创建的线程数。官方规定此值必须大于 0,也必须大于等于 corePoolSize,此值只有在任务比较多,且不能存放在任务队列时,才会用到。
第 3 个参数:keepAliveTime 表示线程的存活时间,当线程池空闲时并且超过了此时间,多余的线程就会销毁,直到线程池中的线程数量销毁的等于 corePoolSize 为止,如果 maximumPoolSize 等于 corePoolSize,那么线程池在空闲的时候也不会销毁任何线程。
第 4 个参数:unit 表示存活时间的单位,它是配合 keepAliveTime 参数共同使用的。
第 5 个参数:workQueue 表示线程池执行的任务队列,当线程池的所有线程都在处理任务时,如果来了新任务就会缓存到此任务队列中排队等待执行。
第 6 个参数:threadFactory 表示线程的创建工厂,此参数一般用的比较少,我们通常在创建线程池时不指定此参数,它会使用默认的线程创建工厂的方法来创建线程,源代码如下:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
// Executors.defaultThreadFactory() 为默认的线程创建工厂
this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
Executors.defaultThreadFactory(), defaultHandler);
}
public static ThreadFactory defaultThreadFactory() {
return new DefaultThreadFactory();
}
// 默认的线程创建工厂,需要实现 ThreadFactory 接口
static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
// 创建线程
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false); // 创建一个非守护线程
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY); // 线程优先级设置为默认值
return t;
}
}
我们也可以自定义一个线程工厂,通过实现 ThreadFactory 接口来完成,这样就可以自定义线程的名称或线程执行的优先级了。
第 7 个参数:RejectedExecutionHandler 表示指定线程池的拒绝策略,当线程池的任务已经在缓存队列 workQueue 中存储满了之后,并且不能创建新的线程来执行此任务时,就会用到此拒绝策略,它属于一种限流保护的机制。
4.2 线程池的工作流程
线程池的工作流程要从它的执行方法 execute() 说起,源码如下:
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
// 当前工作的线程数小于核心线程数
if (workerCountOf(c) < corePoolSize) {
// 创建新的线程执行此任务
if (addWorker(command, true))
return;
c = ctl.get();
}
// 检查线程池是否处于运行状态,如果是则把任务添加到队列
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次检查线程池是否处于运行状态,防止在第一次校验通过后线程池关闭
// 如果是非运行状态,则将刚加入队列的任务移除
if (! isRunning(recheck) && remove(command))
reject(command);
// 如果线程池的线程数为 0 时(当 corePoolSize 设置为 0 时会发生)
else if (workerCountOf(recheck) == 0)
addWorker(null, false); // 新建线程执行任务
}
// 核心线程都在忙且队列都已爆满,尝试新启动一个线程执行失败
else if (!addWorker(command, false))
// 执行拒绝策略
reject(command);
}
其中 addWorker(Runnable firstTask, boolean core) 方法的参数说明如下:
- firstTask:线程应首先运行的任务,如果没有则可以设置为 null;
- core:判断是否可以创建线程的阀值(最大值),如果等于 true 则表示使用 corePoolSize 作为阀值,false 则表示使用 maximumPoolSize 作为阀值。
4.3 ThreadPoolExecutor 的执行方法有几种?它们有什么区别?
execute() VS submit()
execute() 和 submit() 都是用来执行线程池任务的,它们最主要的区别是,submit() 方法可以接收线程池执行的返回值,而 execute() 不能接收返回值。
submit() 方法可以配合 Futrue 来接收线程执行的返回值。它们的另一个区别是 execute() 方法属于 Executor 接口的方法,而 submit() 方法则是属于 ExecutorService 接口的方法,它们的继承关系如下图所示:
4.4 什么是线程的拒绝策略?拒绝策略的分类有哪些?如何自定义拒绝策略?
当线程池中的任务队列已经被存满,再有任务添加时会先判断当前线程池中的线程数是否大于等于线程池的最大值,如果是,则会触发线程池的拒绝策略。
Java 自带的拒绝策略有 4 种:
- AbortPolicy,终止策略,线程池会抛出异常并终止执行,它是默认的拒绝策略;
- CallerRunsPolicy,把任务交给当前线程来执行;
- DiscardPolicy,忽略此任务(最新的任务);
- DiscardOldestPolicy,忽略最早的任务(最先加入队列的任务)。
自定义拒绝策略
自定义拒绝策略只需要新建一个 RejectedExecutionHandler 对象,然后重写它的 rejectedExecution() 方法即可。
4.5 ThreadPoolExecutor 能不能实现扩展?如何实现扩展?
ThreadPoolExecutor 的扩展主要是通过重写它的 beforeExecute() 和 afterExecute() 方法实现的,我们可以在扩展方法中添加日志或者实现数据统计,比如统计线程的执行时间,如下代码所示:
/**
* 线程池扩展
*/
public class MyThreadPoolExecutor extends ThreadPoolExecutor {
// 保存线程执行开始时间
private final ThreadLocal<Long> localTime = new ThreadLocal<>();
public MyThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
TimeUnit unit, BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
/**
* 开始执行之前
* @param t 线程
* @param r 任务
*/
@Override
protected void beforeExecute(Thread t, Runnable r) {
Long sTime = System.nanoTime(); // 开始时间 (单位:纳秒)
localTime.set(sTime);
System.out.println(String.format("%s | before | time=%s",
t.getName(), sTime));
super.beforeExecute(t, r);
}
/**
* 执行完成之后
* @param r 任务
* @param t 抛出的异常
*/
@Override
protected void afterExecute(Runnable r, Throwable t) {
Long eTime = System.nanoTime(); // 结束时间 (单位:纳秒)
Long totalTime = eTime - localTime.get(); // 执行总时间
System.out.println(String.format("%s | after | time=%s | 耗时:%s 毫秒",
Thread.currentThread().getName(), eTime, (totalTime / 1000000.0)));
super.afterExecute(r, t);
}
}
}
public class ThreadPoolExtend {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 线程池扩展调用
MyThreadPoolExecutor executor = new MyThreadPoolExecutor(2, 4, 10,
TimeUnit.SECONDS, new LinkedBlockingQueue());
for (int i = 0; i < 3; i++) {
executor.execute(() -> {
Thread.currentThread().getName();
});
}
}
}
以上程序的执行结果如下所示:
pool-1-thread-1 | before | time=4570298843700
pool-1-thread-2 | before | time=4570298840000
pool-1-thread-1 | after | time=4570327059500 | 耗时:28.2158 毫秒
pool-1-thread-2 | after | time=4570327138100 | 耗时:28.2981 毫秒
pool-1-thread-1 | before | time=4570328467800
pool-1-thread-1 | after | time=4570328636800 | 耗时:0.169 毫秒
5. synchronized
synchronized 属于独占式非公平锁,也是可以冲入锁。是通过 JVM 隐式实现的,synchronized 只允许同一时刻只有一个线程操作资源。
在 Java 中每个对象都隐式包含一个 monitor(监视器)对象,加锁的过程其实就是竞争 monitor 的过程,当线程进入字节码 monitorenter 指令之后,线程将持有 monitor 对象,执行 monitorexit 时释放 monitor 对象,当其他线程没有拿到 monitor 对象时,则需要阻塞等待获取该对象。
在 HotSpot虚拟机中,Monitor 底层是由C++实现的,它的实现对象时 ObjectMonitor,ObjectMonitor结构实现如下:
//结构体如下
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0; //记录该线程获取锁的次数(也就是前前后后这个线程一共获取此锁多少次)
_waiters = 0,
_recursions = 0; //线程的重入次数
_object = NULL;
_owner = NULL; // 线程拥有者,是持有该ObjectMonitor(监视器)对象的线程
_WaitSet = NULL; //待授权集合,存放处于 wait 状态下的线程,当线程执行了 wait()方法后进入WaitSet队列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多线程竞争锁进入时的单向链表
FreeNext = NULL ;
_EntryList = NULL ; //监控集合,存放的是处于阻塞状态的线程队列,在多线程下,竞争失败的线程会进入EntryList队列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
监视器执行流程如下:
- 线程通过CAS尝试获取锁,如果成功就将 _owner 字段设置为当前线程,说明当前线程已经持有锁,并将 _recursions 重入次数 +1。如果获取失败则先通过 自旋 CAS 尝试获取锁,如果还是失败则将当前线程放入到 EntryList 监控队列(阻塞)。
- 当拥有锁的线程执行 wait 方法之后,线程释放锁,将 owner 变量恢复为 null 状态。同时将该线程放入 WaitSet 待授权队列中等待被唤醒。
- 当调用 notify 方法时,随机唤醒 WaitSet 队列中某一个线程,当调用 notifyAll 时唤醒所有的 WaitSet 中的线程尝试获取锁。
- 现在执行完会唤醒 EntryList 中的所有线程尝试获取锁。
总结:
synchronized 同步锁是通过 JVM 内置的 Monitor 监视器实现的,而监视器又是通过依赖操作系统的互斥锁实现的。
6. sleep 和 wait 方法的区别
sleep 方法和 wait 方法都是用来将线程进入休眠状态的。并且 sleep 和 wait 方法都可以响应 interrupt 中断,也就是线程在休眠过程中,如果收到中断信号,都可以进行响应并中断。且都可以抛出 InterruptedException 异常。
他们有什么区别呢?
-
语法使用不同
wait 方法必须配合 synchronized 使用,不然会抛出 IllegalMonitorStateExceptio 异常。
sleep 可以单独使用,无需配合 synchronized 使用。
-
所属类不能
wait 方法属于 Object 类的方法
sleep 属于 Thread 类的方法。
-
唤醒方式不同
sleep 方法必须要传递一个超时时间的参数,且过了超时时间之后线程会自动唤醒
wait 方法可以不传递任何参数,不传递任何参数是表示永久休眠,直到另一个线程调用 notify 或 notifyAll之后休眠的线程被唤醒。
也就是说 sleep 方法具有主动唤醒功能,而不传递任何参数的 wait 方法只能被动唤醒
-
是否锁资源不同
wait 方法会主动释放锁,而 sleep 方法则不会。
-
线程状态不同
sleep 线程处于 timed_waiting状态
wait 线程处于 waiting 状态
7. AQS 源码解析
AQS,全称是 AbstractQueuedSynchronizer,基于 FIFO 队列实现的同步锁的框架。通过一个原子性的 int 值来表示同步器的状态。
AQS 实现了两种模式,独占模式和共享模式,分别用于实现独占锁和共享锁。
独占锁有:
ReentrantLock
ReentrantReadWriteLock
共享锁有:
CountDownLatch
Semaphore
以独占模式为例,队列中头节点表示已经获取到了锁,其他节点都处于等待状态。释放锁之后,头节点的下一个节点会尝试去获取锁,但并不意味着一定会成功,如果失败将会继续等待。
7.1 内部类 Node
AQS 有两个内部类:Node 和 ConditionObject,这里只介绍 Node 类
Node 类是等待队列中的节点类,是一个基于 FIFO 的双向队列
主要的属性及方法如下:
static final class Node {
/** 表示节点在共享模式下等待 */
static final Node SHARED = new Node();
/** 表示节点正在独占模式下等待 */
static final Node EXCLUSIVE = null;
/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED = 1;
/** waitStatus value to indicate successor's thread needs unparking */
static final int SIGNAL = -1;
/** waitStatus value to indicate thread is waiting on condition */
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
static final int PROPAGATE = -3;
/**
* SIGNAL: 当前节点的线程完成或者取消后, 需要设置为此状态以便唤醒后节点的线程
* CANCELLED: 节点会被取消如果获取锁超时或者线程被打断, 设置为此状态的节点不会再改变其状态
* CONDITION: 表示节点处于 Condtion 队列中, 不会被用作同步队列节点
* PROPAGATE: 用于共享模式下的传播释放等待线程
* 0: 初始值
*
* 通过 CAS 来改变属性值
*/
volatile int waitStatus;
/**
* 指向前节点
* 当前节点被取消时, 需要指向新的未被取消的节点作为前节点,
* 头节点不会被取消:因为节点成为头节点就意味着成功获取到了锁, 一个被取消的节点永远不会获取到锁
*/
volatile Node prev;
/**
* 指向后节点, 当后节点被取消时需要指向新的节点或者空
*/
volatile Node next;
/**
* 入队节点的线程, 结束之后会被置空
*/
volatile Thread thread;
/**
*
* Condition 队列中的下一个等待节点
*/
Node nextWaiter;
}
7.2 主要属性
AQS 主要属性说明如下
/**
* 等待队列的头节点, 赖加载
* 除了初始化之外, 只能通过 setHead 方法来改变其值
* 如果 head 不为 null, waitStatus 值就一定不会是 CANCELLED
*/
private transient volatile Node head;
/**
* 等待队列的尾结点, 懒加载
* 只能通过 enq 方法添加新节点时才会去改变尾结点
*/
private transient volatile Node tail;
/**
* 同步器的状态
* 以 ReentrantLock 为例, 0 表示可以获取到锁, 其他的正整数表示无法获取到锁
*/
private volatile int state;
7.3 方法
7.3.1 获取锁
-
acquire
获取独占锁,方法的逻辑是先尝试获取锁,如果失败,就往队列末尾添加一个节点,再尝试从队列里获取锁。
public final void acquire(int arg) { // 尝试获取锁 if (!tryAcquire(arg) && // acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
-
tryAcquire
尝试获取独占锁,需要由子类自己实现,体现了 Abstract
protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }
-
addWaiter
往尾部添加一个节点作为新的尾结点。如果尾结点未初始化,则通过 enq 完成初始化
private Node addWaiter(Node mode) { // 创建当前线程的节点 Node node = new Node(Thread.currentThread(), mode); // 尾节点作为前驱 Node pred = tail; if (pred != null) { node.prev = pred; // 通过 cas 将 tail 指向新的节点 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // 完成尾结点初始化和设置的工作 enq(node); return node; }
-
acquireQueued
不断尝试从队列中获取锁,当成功获取到锁或者线程被打断时会成功退出循环,竞争锁失败的线程会等待直到被唤醒,唤醒之后会再次进入循环尝试去获取锁,不断的重复整个过程。
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; // 不断尝试去获取锁直到成功或线程被打断 for (;;) { // 获取当前节点的前节点 final Node p = node.predecessor(); // 如果前节点是头部节点, 就尝试去获取锁 if (p == head && tryAcquire(arg)) { // 成功之后将节点设置为新的头部节点 // 可以理解为头部节点就是获取到锁的节点 setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 获取锁失败后进到这里 // 先判断线程是否需要 park, 如果是就执行 park, 否则再进入循环 // 这里还判断了线程是否已经被打断了, 如果是, 就会执行 cancelAcquire if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
-
acquireShared
获取共享锁,方法逻辑是先尝试获取锁,如果失败,就不断尝试去获取锁。
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }
-
tryAcquireShared
尝试获取共享锁,需要由子类自己实现。
protected int tryAcquireShared(int arg) { throw new UnsupportedOperationException(); }
-
doAcquireShared
不断尝试从队列中获取共享锁,当成功获取到锁或者线程被打断时会成功退出循环,竞争锁失败的线程会被 park 直到被唤醒,唤醒之后会再次进入循环尝试去获取锁,不断的重复整个过程。
private void doAcquireShared(int arg) { // 在队列末尾加入节点, 模式为 SHARED final Node node = addWaiter(Node.SHARED); boolean failed = true; try { boolean interrupted = false; // 不断尝试获取锁 for (;;) { final Node p = node.predecessor(); if (p == head) { // 如果前节点是头节点, 就尝试去获取锁 int r = tryAcquireShared(arg); if (r >= 0) { // 如果获取锁成果, 就将当前节点设置为新的头节点 setHeadAndPropagate(node, r); p.next = null; // help GC if (interrupted) selfInterrupt(); failed = false; return; } } // 判断是否需要 park 以及线程是否被打断 // 同 acquireQueued 方法 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
7.3.2 释放锁
-
release
释放独占锁,方法逻辑是首先尝试释放锁,成功后,如果头节点不为空而且 waitStatus 不为 0(即节点不是初始化的状态),就唤醒后节点的线程。
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
-
tryRelease
尝试释放独占锁,需要由子类实现。
protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
-
releaseShared
是否共享锁,逻辑是先尝试释放锁,如果成功,再依次去释放节点上阻塞的线程。
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }
-
tryReleaseShared
尝试释放共享锁,需要由子类实现。
protected boolean tryReleaseShared(int arg) { throw new UnsupportedOperationException(); }
-
doReleaseShared
释放共享锁。
private void doReleaseShared() { /* * Ensure that a release propagates, even if there are other * in-progress acquires/releases. This proceeds in the usual * way of trying to unparkSuccessor of head if it needs * signal. But if it does not, status is set to PROPAGATE to * ensure that upon release, propagation continues. * Additionally, we must loop in case a new node is added * while we are doing this. Also, unlike other uses of * unparkSuccessor, we need to know if CAS to reset status * fails, if so rechecking. */ for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; // 如果 waitStatus 为 SIGNAL, 表示可以去释放锁 if (ws == Node.SIGNAL) { // 通过 cas 将 waitStatus 设为 0 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // loop to recheck cases // 唤醒当前节点后节点的线程 unparkSuccessor(h); } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; // loop on failed CAS } if (h == head) // loop if head changed break; } }
7.4 独占锁和共享锁的异同
相同点
-
获取锁前都会判断是否有权限,只有满足条件才可能获取到锁
-
未获取到锁的线程会创建新节点放入队列尾部
不同点
-
独占锁只会释放头部后节点的线程,而共享锁会依次释放所有线程
-
独占锁存在非公平锁的情况,新的线程可能抢占队列中线程的锁,共享锁则不存在这种情况
7.5 子类实现
7.5.1 ReetrankLock
ReetrankLock 中的 Sync 内部类实现了 AQS,是一种独占锁,下面来看下它对 AQS 的实现(以 NonfairSync 为例)
-
tryAcquire
对 AQS 中 tryAcquire 的实现:
protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // state == 0 表示此线程有权限去获取锁 if (c == 0) { // 通过 CAS 保证只有一个线程可以顺利获取到锁 if (compareAndSetState(0, acquires)) { // 设置 exclusiveOwnerThread 为当前线程, 用于实现可重入 setExclusiveOwnerThread(current); return true; } } // 这里表示获取锁的是当前线程, 提供可重入功能 else if (current == getExclusiveOwnerThread()) { // 用于 state 计数, 每次 unlock 之后都会减 1 int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
-
tryRelease
对 AQS 中 tryRelease 的实现:
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { // state == 0 表示锁完全释放, 此时别的线程可以去争夺锁 free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
7.5.2 CountDownLatch
CountDownLatch 中的 Sync 内部类实现了 AQS,是一种共享锁,下面来看下它对 AQS 的实现。
-
tryAcquireShared
state 为 0 时表示可以获取到锁:
protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; }
-
tryReleaseShared
state 最终为 0 表示释放锁成功。
protected boolean tryReleaseShared(int releases) { // Decrement count; signal when transition to zero for (;;) { int c = getState(); // 此时不需要去释放锁 if (c == 0) return false; int nextc = c-1; if (compareAndSetState(c, nextc)) return nextc == 0; } }
7.6 总结
AQS,基于 FIFO 队列实现的同步锁的框架。通过一个原子性的 int 值来表示同步器的状态。AQS 实现了两种模式,独占模式和共享模式,分别用于实现独占锁和共享锁。其实现原理如下:
8. synchronized 和 ReentrantLock 的区别
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁,二者的主要区别有以下5点:
-
用法不同
synchronized 可用来修饰普通方法、静态方法和代码块。而 ReentrantLock 只能用在代码块上。
ReentrantLock 在使用之前需要创建 ReentrantLock 对象,然后使用 lock 方法加锁,使用完之后再调用 unlock 方法释放锁。
-
获取锁和释放锁的方式不同
synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开 synchronized 的代码块后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁。在使用 ReentrantLock 的 unlock 释放锁的操作一定要在 finally 中,否则有可能出现锁一致被占用,从而导致其他线程一直阻塞的问题。
-
锁类型不同
synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁,默认为非公平锁。
-
响应中断不同
ReentrantLock 可以使用 lockInterruptibly 获取锁并响应中断指令,而 synchronized 不能响应中断。也就是如果发生了死锁,使用 synchronized 会一直等待下去。而使用 ReentrantLock 可以响应中断并释放锁,从而解决死锁的问题。
-
底层实现不同
synchronized 是 JVM 层面通过监视器(Monitor)实现的。而 ReentrantLock 是通过 AQS 程序级别的 API 实现的。
9. int 和 Integer 的区别
包装类及其作用,因为 Java 的设计理念是一切皆是对象。在很多情况下需要以对象的形式操作。比如 hashCode() 获取哈希值 或者 getClass()获取类等。包装类的作用,在 Java 中每个基本数据类型都有对应的包装类。而 int 对应的包装类就是 Integer 。包装类的存在解决了基本数据类型无法做到的 泛型、序列化、类型转换、高频区间数据缓存等问题。
int 和 Ineger 的区别主要体现在以下几个方面:
- 数据类型不同:int 是基础数据类型,而 Integer 是包装数据类型。
- 默认值不同:int 的默认值是 0,Integer 的默认值是 null。
- 内存中存储的方式不同:int 在内存中直接存储数据值,而 Integer 实际存储的是对象引用。当 new 一个 Integer 时实际上是生成一个指针指向此对象。
- 实例化方式不同:Integer 必须实例化才可以使用,而 int 不需要。
- 变量的比较方式不同:int 可以使用 == 来比较,而 Integer 一定要使用 equals 来比较两个变量是否相等。
10. 重写 equals 时为什么一定要重写 hashCode ?
equals 和 hashCode 是 Object 中的2个基础方法,它们共同协作来判断2个对象是否相等,这样设计的好处是效率更高。因为通过 hashCode 的值我们就可以直接定位到一个数据的存储位置。而不需要一个一个的循环查找。
为什么要重新 equals?默认 equals 的方法如下:
public boolean equals(Object obj) {
return (this == obj);
}
比较的是2个对象的引用地址是否相等,是没有意义的。如果需要比较2个自定义对象是否相等,结果一定是 false。就没有使用意义了。因此通常情况下我们要判断2个对象是否相等,需要重写 equals 方法。
重写 equals 时为什么一定要重写 hashCode?
hashCode 是一个散列码,它是由对象推导出的一个整型值。散列码是没有规律的。如果 x 和 y 是两个不同的对象。x.hashCode() 和 y.hashCode() 基本上不会相同。但如果 a 和 b 相等,则 a.hashCode() 一定等于 b.hashCode()。为了解释重写 equals 时为什么一定要重写 hashCode?以 set 集合去重为例:
我们只重写 equals 方法发现不会达到去重的效果,当重写了 hashCode 和 equals 方法后达到了去重的效果。因为 set 集合去重的时候会使用 hashCode 取模获取 table 数据下班,从而定位到元素进行比较。
11. ThreadLocal 的实现原理?
11.1 ThreadLocal简介
ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来。
既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。
下图可以增强理解:
11.2 ThreadLocal与Synchronized的区别
ThreadLocal其实是与线程绑定的一个变量。ThreadLocal和Synchonized都用于解决多线程并发访问。
但是ThreadLocal与synchronized有本质的区别:
-
Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。
-
Synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问。而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。而Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。
一句话理解ThreadLocal,向ThreadLocal里面存东西就是向它里面的Map存东西的,然后ThreadLocal把这个Map挂到当前的线程底下,这样Map就只属于这个线程了。
11.3 ThreadLocal的原理
要看原理那么就得从源码看起。
11.3.1 ThreadLocal的set()方法
public void set(T value) {
//1、获取当前线程
Thread t = Thread.currentThread();
//2、获取线程中的属性 threadLocalMap ,如果threadLocalMap 不为空,
//则直接更新要保存的变量值,否则创建threadLocalMap,并赋值
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
// 初始化thradLocalMap 并赋值
createMap(t, value);
}
从上面的代码可以看出,ThreadLocal set赋值的时候首先会获取当前线程thread,并获取thread线程中的ThreadLocalMap属性。如果map属性不为空,则直接更新value值,如果map为空,则实例化threadLocalMap,并将value值初始化。
那么ThreadLocalMap又是什么呢,还有createMap又是怎么做的,我们继续往下看。大家最后自己再idea上跟下源码,会有更深的认识。
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
可看出ThreadLocalMap是ThreadLocal的内部静态类,而它的构成主要是用Entry来保存数据 ,而且还是继承的弱引用。在Entry内部使用ThreadLocal作为key,使用我们设置的value作为value。详细内容要大家自己去跟。
//这个是threadlocal 的内部方法
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
//ThreadLocalMap 构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
11.3.2 ThreadLocal的get方法
public T get() {
//1、获取当前线程
Thread t = Thread.currentThread();
//2、获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
//3、如果map数据为空,
if (map != null) {
//3.1、获取threalLocalMap中存储的值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//如果是数据为null,则初始化,初始化的结果,TheralLocalMap中存放key值为threadLocal,值为null
return setInitialValue();
}
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
11.3.3 ThreadLocal的remove方法
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
remove方法,直接将ThrealLocal 对应的值从当前相差Thread中的ThreadLocalMap中删除。为什么要删除,这涉及到内存泄露的问题。
实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,弱引用的特点是,如果这个对象只存在弱引用,那么在下一次垃圾回收的时候必然会被清理掉。
所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。
ThreadLocal其实是与线程绑定的一个变量,如此就会出现一个问题:如果没有将ThreadLocal内的变量删除(remove)或替换,它的生命周期将会与线程共存。通常线程池中对线程管理都是采用线程复用的方法,在线程池中线程很难结束甚至于永远不会结束,这将意味着线程持续的时间将不可预测,甚至与JVM的生命周期一致。举个例字,如果ThreadLocal中直接或间接包装了集合类或复杂对象,每次在同一个ThreadLocal中取出对象后,再对内容做操作,那么内部的集合类和复杂对象所占用的空间可能会开始持续膨胀。
12. 预估对象大小
12.1基础数据类型大小
所有对象的都是继承 Object 对象,看下 Object 对象的大小:
4byte+8byte。4byte 是 Java栈中保存引用的所需要的空间。而那 8byte 则是Java堆中对象的信息。
有了Object对象的大小,我们就可以计算其他对象的大小了。
public class NewObject {
int count;
boolean flag;
Object ob;
}
其大小为:空对象大小(8byte)+int大小(4byte)+Boolean大小(1byte)+空Object引用的大小 (4byte)=17byte。但是因为Java在对对象内存分配时都是以8的整数倍来分,因此大于17byte的最接 近8的整数倍的是24,因此此对象的大小约为24byte。
13. 类加载
13.1 类加载的过程
类加载过程有如下几步:
加载 >> 验证 >> 准备 >> 解析 >> 初始化 >> 使用 >> 卸载
-
加载:在硬盘上查找并通过IO读入字节码文件,使用到类时才会加载,例如调用类的 main()方法,new对象等等,在加载阶段会在内存中生成一个代表这个类的 java.lang.Class对象,作为方法区这个类的各种数据的访问入口
-
验证:校验字节码文件的正确性
-
准备:给类的静态变量分配内存,并赋予默认值
-
解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用。
-
初始化:对类的静态变量初始化为指定的值,执行静态代码块。
类被加载到方法区中后主要包含 运行时常量池、类型信息、字段信息、方法信息、类加载器的引用、对应class实例的引用等信息。
类加载器的引用:这个类到类加载器实例的引用
对应class实例的引用:类加载器在加载类信息放到方法区中后,会创建一个对应的Class 类型的对象实例放到堆(Heap)中, 作为开发人员访问方法区中类定义的入口和切入点。
13.2 类加载器和双亲委派机制
13.2.1 类加载器
类加载过程主要是通过类加载器来实现的,Java里有如下几种类加载器:
-
引导类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的核心类库,比如rt.jar、charsets.jar等。
-
扩展类加载器:负责加载支撑JVM运行的位于JRE的lib目录下的ext扩展目录中的 jar 包类。
-
应用程序类加载器:负责加载 ClassPath 路径下的类包,主要就是加载你自己写的那些类。
-
自定义加载器:负责加载用户自定义路径下的类包。
13.2.2 双亲委派机制
JVM类加载器是有亲子层级结构的,如下图:
类加载其实就有一个双亲委派机制,加载某个类时会先委托父加载器寻找目标类,找不到再委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。
为什么要设计双亲委派机制?
- 沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样便可以防止核心 API 库被随意篡改。
- 避免类的重复加载:当父亲已经加载了该类时,就没有必要子 ClassLoader 再加载一次,保证被加载类的唯一性。
13.2.3 自定义类加载器
自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是 loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass,默认实现是空方法,所以我们自定义类加载器主要是重写findClass方法。
package com.stock;
import java.io.FileInputStream;
import java.lang.reflect.Method;
public class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
private byte[] loadByte(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] bytes = loadByte(name);
Class<?> clazz = defineClass(name, bytes, 0, bytes.length);
return clazz;
}catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
public static void main(String[] args) throws Exception{
MyClassLoader classLoader = new MyClassLoader("E:\\maven\\stock\\stock-data-collect\\target\\test-classes\\");
Class<?> clazz = classLoader.findClass("com.stock.UserTest");
Object o = clazz.newInstance();
Method method = clazz.getDeclaredMethod("testClassLoader");
method.invoke(o,null);
System.out.println(clazz.getClassLoader().getClass());
// Method[] methods = clazz.getDeclaredMethods();
// for (Method method : methods) {
// method.invoke(o,null);
// }
}
}
输出结果:
测试类加载
class com.stock.MyClassLoader
重写loadClass方法打破双亲委派:
/**
* 重写类加载发放,实现自己的加载逻辑,不委派给双亲加载
* @param name
* @param resolve
* @return
* @throws ClassNotFoundException
*/
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (name) {
Class<?> c = findLoadedClass(name);
if (c == null) {
long t1 = System.nanoTime();
c = findClass(name);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
14. G1收集器(-XX:+UseG1GC)
14.1 G1收集器介绍
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M。当然也可以用参数- XX:G1HeapRegionSize
手动指定Region大小,但是推荐默认的计算方式。
G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合。默认年轻代占堆内存的5%。如果堆大小为4096M,那么年轻代占据200MB左右的内存,对应大概是100个Region,可以通过-XX:G1NewSizePercent
设置新生代初始占比。在系统运行中,JVM会不停的给年轻代增加更多 的Region,但是最多新生代的占比不会超过60%,可以通过-XX:G1MaxNewSizePercent
调整。年轻代中的Eden和 Survivor对应的region也跟之前一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100 个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配
大对象的Region叫Humongous区,而不是让大对象直接进入老年代的Region中。在G1中,大对象的判定规则就是一 个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放 入Humongous中,而且一个大对象如果太大,可能会横跨多个Region来存放。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老年代空间不够产生的GC开销。
Full GC 的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
14.2 垃圾回收过程
G1收集器一次GC的运作过程大致分为以下几个步骤:
-
初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
-
并发标记(Concurrent Marking):同CMS的并发标记。
-
最终标记(Remark,STW):同CMS的重新标记 。
-
筛选回收Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序**,根据用户所期 望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个Region刚好需要200ms,那么就只会回收800个Region(Collection Set,要回收的集合),尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个region中,这种不会像CMS那样 回收完因为有很多内存碎片还需要整理一次,G1采用复制算法回收几乎不会有太多内存碎片。(注意:CMS回收阶段是跟用户线程一起并发执行的,G1因为内部实现太复杂暂时没实现并发回收,不过到了Shenandoah就实现了并发收集,Shenandoah可以看成是G1的升级版本) 。
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的Region(这也就是它的名字( Garbage-First的由来),比如一个Region花200ms能回收10M垃圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间内可以尽可能高的收集效率。
14.3 G1的优点
被视为JDK1.7以上版本Java虚拟机的一个重要进化特征。它具备以下特点:
-
并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop- The-World 停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行。
-
分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
-
空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法实现的收集器;从局部上来看是基于“复制”算法实现的。
-
可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数
-XX:MaxGCPauseMillis
指定)内完成垃圾收集。
毫无疑问, 可以由用户指定期望的停顿时间是G1收集器很强大的一个功能, 设置不同的期望停顿时间, 可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。 不过, 这里设置的“期望值”必须是符合实际的, 不能异想天开, 毕竟G1是要冻结用户线程来复制对象的, 这个停顿时间再怎么低也得有个限度。 它默认的停顿目标为两百毫秒, 一般来说, 回收阶段占到几十到一百甚至接近两百毫秒都很正常, 但如果我们把停顿时间调得非常低, 譬如设置为二十毫秒, 很可能出现的结果就是由于停顿目标时间太短, 导致每次选出来的回收集只占堆内存很小的一部分, 收集器收集的速度逐渐跟不上分配器分配的速度, 导致垃圾慢慢堆积。 很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间, 但应用运行时间一长就不行了, 最终占满堆引发Full GC反而降低性能, 所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
14.4 G1垃圾收集分类
-
YoungGC
YoungGC 并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代的 region,继续给新对象存放,不会马上做Young GC,直到下一次 Eden 区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC 。
-
MixedGC(混合回收)
不是 FullGC,老年代的堆占有率达到参数(
-XX:InitiatingHeapOccupancyPercent
) 设定的值则触发,回收所有的 Young 和 部分Old (根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做 MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现 没有足够的空region 能够承载拷贝对象就会触发一次Full GC 。 -
Full GC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次 MixedGC 使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了) 。
14.5 G1收集器参数设置
-XX:+UseG1GC
:使用G1收集器
-XX:ParallelGCThreads
:指定GC工作的线程数量
-XX:G1HeapRegionSize
:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区
-XX:MaxGCPauseMillis
:目标停顿时间(默认200ms)
-XX:G1NewSizePercent
:新生代内存初始空间(默认整堆5%)
-XX:G1MaxNewSizePercent
:新生代内存最大空间
-XX:TargetSurvivorRatio
:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代 。
-XX:MaxTenuringThreshold
:最大年龄阈值(默认15)
-XX:InitiatingHeapOccupancyPercent
:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了。
-XX:G1MixedGCLiveThresholdPercent
:(默认85%) ,region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。
-XX:G1MixedGCCountTarget
:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。
-XX:G1HeapWastePercent(默认5%)
: gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。
14.6 G1垃圾收集器优化建议
假设参数 -XX:MaxGCPauseMills
设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。那么存活下来的对象可能就会很多,此时就会导致 Survivor 区域放不下那么多的对象,就会进入老年代中。或者是年轻代 gc 过后,存活下来的对象过多,导致进入 Survivor 区域后触发了动态年龄判定规则,达到了Survivor 区域的50%,也会快速导致一些对象进入老年代中。所以这里核心还是在于调节 -XX:MaxGCPauseMills
这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发 mixed gc(混合回收).
14.7 什么场景适合使用G1
-
50%以上的堆被存活对象占用
-
对象分配和晋升的速度变化非常大
-
垃圾回收时间特别长,超过1秒
-
8GB以上的堆内存(建议值)
-
停顿时间是500ms以内
14.8 每秒几十万并发的系统如何优化JVM
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的。一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理。这里就涉及到一个问题了,我们以前常说的对于 eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要几秒钟,按kafka这个并发量放满三四十G的 eden 区可能也就一两分钟吧,那么意味着整个系统每运行一两分钟就会因为young gc 卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills
为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。 G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
14.9 如何选择垃圾收集器
-
优先调整堆的大小让服务器自己来选择
-
如果内存小于100M,使用串行收集器
-
如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
-
如果允许停顿时间超过1秒,选择并行或者JVM自己选
-
如果响应时间最重要,并且不能超过1秒,使用并发收集器
-
4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC
下图有连线的可以搭配使用
JDK 1.8默认使用 Parallel(年轻代和老年代都是)
JDK 1.9默认使用 G1
15. 查看jvm线程cpu占用
先top定位到进程ID,然后查看线程CPU占用:
ps H -eo pid,tid,%cpu|grep 1854
然后10进制线程ID转16进制线程id:https://jisuan5.com/decimal
再通过 jstack 查看线程栈。