前言
本专栏系列文章仅用于个人学习总结,从零开始逐步到常见服务框架。觉得基础的大佬可以提前离开。欢迎各位大佬评论指教,如有不当之处请及时联系调整 ~
本专栏工作之余抽空更新面试题…
上一篇文章地址:2.java基础进阶篇
说明:计算机在运行时,先从内存中取出第一条指令,通过控制器的译码,按指令的要求,从存储器中取出数据进行指定的运算和逻辑操作等加工,然后再按地址把结果送到内存中去。接下来,再取出第二条指令,在控制器的指挥下完成规定操作。依此进行下去。直至遇到停止指令。(自动地完成指令规定的操作是计算机最基本的工作模型)
说明:在CPU访问存储设备时,无论是存取数据抑或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。
举个空间局部性原则例子:(第一个案例速度更快)
第一个案例是 longs[0][0] + longs[0][1] + longs[0][2] …longs[0][1024] 一直相加;
第二个案例是 longs[0][0] + longs[1][0] + longs[2][0] …longs[1024][0] 一直相加;
第二个案例一维数组都变了,由空间局部性原则可知, 第二个案例不符合空间局部性原则, 无法读取到附近引用的,效率低。
Linux与Windows只用到了2个级别: ring0、ring3。
操作系统内部内部程序指令通常运行在ring0级别,操作系统以外的第三方程序运行在ring3级别。
第三方程序如果要调用操作系统内部函数功能,由于运行安全级别不够,必须切换CPU运行状态,从ring3切换到ring0,然后执行系统函数。
JVM创建线程CPU的工作过程:
step1:CPU从ring3切换ring0创建线程
step2:创建完毕,CPU从ring0切换回ring3
step3:线程执行JVM程序
step4:线程执行完毕,销毁还得切回ring0
说到这里相信同学们明白为什么JVM创建线程,线程阻塞唤醒是重型操作了,因为CPU要切换运行状态(用户态、内核态的上下文切换)。
用户空间: 指的就是用户可以操作和访问的空间,这个空间通常存放我们用户自己写的数据等(例 java 的 JVM)。
内核空间: 是系统内核来操作的一块空间,这块空间里面存放系统内核的函数、接口等。
线程通常有五种状态:
阻塞的情况又分为三种:
锁池及等待池说明:
sleep 和 wait 的区别:
sleep可以理解为一个人在睡觉时,把锁捏在了手里,这样导致其他人也无法获取锁。而wait则是一个人临时有事,把锁让出来给其他人。(如果在睡眠期间其他线程调用了这个线程的interrupt方法,那么这个线程也会抛出interruptexception异常返回,这点和wait是一样的)
yield():
个人感觉yield 方法意义不是很大,在就绪状态的线程,还是会去争夺cpu调度。或许只有AbstractQueuedSynchronizer(AQS)这类需要自旋等待的功能较适用。
join():
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("子线程输出");
});
t1.start();
t1.join();
// 这行代码必须要等t1全部执行完毕,才会执行
System.out.println("主线程输出");
}
说明:不是线程安全、应该是内存安全,堆是共享内存,可以被所有线程访问。
当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的。
堆: 是进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。
在Java中,堆是Java虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
栈: 是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的(多线程操作堆才会有所谓的线程不安全)。操作系统在切换线程的时候会自动切换栈。(栈空间不需要在高级语言里面显式的分配和释放)
目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。
在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。
守护线程: 为所有非守护线程提供服务的线程(即任何一个守护线程都是整个JVM中所有非守护线程的保姆)
守护线程类似于整个进程的一个默默无闻的小喽喽;它的生死无关重要,它却依赖整个进程而运行;哪天其他线程结束了,没有要执行的了,程序就结束了,理都没理守护线程,就把它中断了。
感觉不靠谱?守护线程其实也有优点(作用):
GC垃圾回收线程:就是一个经典的守护线程。当我们的程序中不再有任何运行的Thread,程序就不会再产生垃圾,垃圾回收器也就无事可做,所以当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。(它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。)
应用场景:
反例:如果一个正在执行某个操作的线程必须要正确地关闭掉否则就会出现不好的后果的话,那么这个线程就不能是守护线程,而是用户线程。通常都是些关键的事务,比方说,数据库录入或者更新,这些操作都是不能中断的。
注意点:
需知: 每一个 Thread 对象均含有一个 ThreadLocal.ThreadLocalMap类型的成员变量,它存储本线程中所有ThreadLocal对象及其对应的值。
ThreadLocalMap (简单理解为Map结构) 由一个个 Entry 对象构成,一个 Entry 由 ThreadLocal 对象和 Object 构成。由此可见, Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强引用后,该key就会被垃圾收集器回收。
使用说明:
由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。
使用场景:
Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑定的connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离。
强引用: 使用最普遍的引用(new),一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。(例 User a = new User(); a 就是对 堆中new User的强引用)
如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。
软引用: JVM进行垃圾回收时,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。在java中用 SoftReference类表示。(可用在浏览器的后退,前进)
弱引用: JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用 WeakReference类来表示。(较少用。没有外部强引用它时,gc回收一定会收拾它)
注意:WeakReference引用本身是强引用,它内部的(T reference)才是真正的弱引用字段,WeakReference就是一个装弱引用的容器而已。使用时,将对象用WeakReference软引用类型的对象包裹即可
虚引用: 幽灵引用,几乎不用。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知(将其与ReferenceQueue关联)。在java中用 PhantomReference类表示。
案例:
/**
* @author wook
* @date 2021/5/11 16:54
*/
public class Refrence {
public static void main(String... args) {
RefrenceStuduet studentA = new RefrenceStuduet("AA", 1);
RefrenceStuduet studentB = new RefrenceStuduet("BB", 2);
RefrenceStuduet studentC = new RefrenceStuduet("CC", 3);
//强引用strongStudentA
RefrenceStuduet strongStudentA = studentA;
//软引用softStudentB
SoftReference<RefrenceStuduet> softStudentB = new SoftReference<>(studentB);
//弱引用weekStudentC
WeakReference<RefrenceStuduet> weekStudentC = new WeakReference<>(studentC);
//直接弱引用
WeakReference<RefrenceStuduet> weekStudentD = new WeakReference<>(new RefrenceStuduet("DD", 4));
//引用都变为null
studentA = null;
studentB = null;
studentC = null;
System.out.println("Before gc...");
System.out.println(String.format("strongA = %sn, softB = %s, weakC = %s, weakD = %s", strongStudentA, softStudentB.get(), weekStudentC.get(), weekStudentD.get()));
System.gc(); //执行系统gc
System.out.println("After gc...");
System.out.println(String.format("strongA = %s, softB = %s, weakC = %s, weakD = %s", strongStudentA, softStudentB.get(), weekStudentC.get(), weekStudentD.get()));
}
}
@Data
@AllArgsConstructor
class RefrenceStuduet {
private String name;
private int age;
}
结果:
Before gc...
strongA = RefrenceStuduet(name=AA, age=1)n, softB = RefrenceStuduet(name=BB, age=2), weakC = RefrenceStuduet(name=CC, age=3), weakD = RefrenceStuduet(name=DD, age=4)
After gc...
strongA = RefrenceStuduet(name=AA, age=1), softB = RefrenceStuduet(name=BB, age=2), weakC = null, weakD = null
案例说明:
内存泄露: 指程序在申请内存后,无法释放已申请的内存空间。(一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光)
不再会被使用的对象或者变量占用的内存不能被gc回收,就是内存泄露。
开始说说ThreadLocal内存泄露原因:
需知: Thread.ThreadLocalMap的Key为弱引用,如果一个ThreadLocal不存在外部强引用时(例显示设置为null),Key(ThreadLocal)势必会被GC回收 ,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉,但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链。(红色链条强引用,虚线为弱引用)
既然会出现上面所述问题,为什么java要将其Key设置为弱引用?
当key 使用强引用:
当ThreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。
当key 使用弱引用:
当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。
总结: ThreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key就会导致内存泄漏,而不是因为弱引用。(个人认为设置为弱引用,反而更能避免内存泄露)
ThreadLocal正确的使用方法:
补充:Java为了最小化内存泄露的可能性和影响,在ThreadLocal的get、set的时候,都会检查当前key所指的对象是否为null,是则删除对应的value,让它能被GC回收。
弱引用demo: (懂的跳过)
public class ThreadLocalDemo {
public static void main(String[] args) throws InterruptedException {
firstStack();
Thread thread = Thread.currentThread();
System.out.println("GC前:" + thread); // 在这里打断点,观察thread对象里的ThreadLocalMap数据
System.gc();
TimeUnit.SECONDS.sleep(1);
System.out.println("GC后:" + thread); // 在这里打断点,观察thread对象里的ThreadLocalMap数据
}
private static A firstStack(){
A a = new A();
System.out.println("value: "+ a.get());
return a;
}
private static class A{
// 如果此处加了 static ,ThreadLocalMap中referent也不会被回收(可以理解为System.gc回收的是堆,而方法区不会被回收)
private ThreadLocal<String> local = ThreadLocal.withInitial(() -> "in class A");
public String get(){
return local.get();
}
public void set(String str){
local.set(str);
}
@Override
protected void finalize() throws Throwable {
System.out.println("我(A)被回收了(该方法第2篇文章有提到)");
}
}
}
结果: ThreadLocalMap中的referent为null(即local被回收),除非将 firstStack(); 改为 A a = firstStack();
说明:
上面的代码,当构造一个A对象时,内部的local对象也构造了,之后调用get和set方法堆local对象取值和设置值,当A对象不可达时,垃圾收集器就会回收A。
现在我们假设ThreadLocalMap的Entey里的key(ThreadLocal对象)不是弱引用的,且已经调用了A的对象的get或set方法,那么垃圾收集器回收A对象时,一定不会回收里面的local对象,为什么?
因为Entey已近持有了local对象的引用,我们没有设置引用类型,那这个引用就默认是个强引用。
Thread -> ThreadLocal.ThreadLocalMap -> Entry[] -> Enrty -> key(threadLocal对象)和value
引用链如上面所示,这个引用链全是强引用,当这个线程还未结束时,他持有的强引用,包括递归下去的所有强引用都不会被垃圾回收器回收。
那么回到正常情况,ThreadLocalMap里Entey的key是弱引用,在本例中也就是local对象在这里是弱引用,当对象A回收时,由于local对象只剩下被弱引用key所引用,所以local对象也会被回收。
结论: 被回收之前,内存泄漏确实存在(key不见了导致无法访问存在的 value),但是调用get,set等都会清理泄漏的Entry。
需知: Java内存模型(JMM):每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。
原子性是指在一个操作中cpu不可以在中途暂停然后再调度,即不被中断操作,要不全部执行完成,要不都不执行。就好比转账,从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。2个操作必须全部完成。
private long count = 0;
public void calc() {
count++;
}
count++ 即 count = count + 1,实际上执行分为四步
1:将 count 从主存读到工作内存中的副本中
2:+1的运算
3:将结果写入工作内存
4:将工作内存的值刷回主存(什么时候刷入由操作系统决定,不确定的)
案例说明: 程序中原子性指的是最小的操作单元,比如自增操作,它本身其实并不是原子性操作,分了3步的,包括读取变量的原始值、进行加1操作、写入工作内存。所以在多线程中,有可能一个线程还没自增完,可能才执行到第二部,另一个线程就已经读取了值,导致结果错误。那如果我们能保证自增操作是一个原子性的操作,那么就能保证其他线程读取到的一定是自增后的数据。
但是还要处理可见性问题,不然即使我们令123 变为原子性,2个线程各自执行完 123原子操作后,在第4步2个线程各自向主存中写入结果 ‘1’,结果也是不对的。
当多个线程访问同一个变量时,其中一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
结合上一个案例说明:若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
如果线程2改变了stop的值,线程1一定会停止吗?不一定。当线程2更改了stop变量的值之后,但是
还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
说明: 虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。(单线程场景不会出问题,多线程会)
/**
* 单例模式:双重校验锁(DCL)
*/
1 public class Singleton {
2 private volatile static Singleton singleton;// 通过volatile关键字来确保安全
3 private Singleton(){
}
4 public static Singleton getInstance(){
5 if(singleton == null){
6 synchronized (Singleton.class){
7 if(singleton == null){
8 singleton = new Singleton();
9 }
10 }
11 }
12 return singleton;
13 }
}
案例说明: 首先要知道,new Singleton()实际上分为三步操作:
(1)分配内存空间
(2)初始化对象
(3)将内存空间的地址赋值给对应的引用
2 3会被处理器优化,发生重排序
当不加volatile修饰,如若A线程new Singleton发生指令重排
总结:
synchronized 的有序性是持有相同锁的两个同步块只能串行的进入,即被加锁的内容要按照顺序被多个线程执行,但是其内部的同步代码还是会发生重排序,使块与块之间有序可见。
volatile的有序性是通过插入内存屏障来保证指令按照顺序执行。不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序。
补充:
1.AtomicInteger 是基于 volatile(保证可见+有序) 和 CAS (保证原子)保证线程安全。
2.Unsafe 的 loadFence等方法也可防止指令重排,compareAndSwapObject 方法就是 CAS。
3.当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中。
为什么用线程池(好处):
工作流程图:
流程概图说明:
① 任务进来,线程池会创建核心线程执行任务,最多创建corePoolSize个核心线程。
② 当任务比较多时,核心线程无法处理完,会将其存放到队列中,直到队列放满。
③ 当队列放满后,此时会创建临时线程处理任务,最多创建maxinumPoolSize - corePoolSize个临时线程。
④ 如果线程池已经没有能力在处理新提交的任务,触发‘拒绝策略’。
线程池ThreadPoolExecutor参数介绍:
阻塞队列的作用(为什么要使用阻塞队列):
为什么是先添加列队而不是先创建最大线程?
需知: 首先要知道,如果反过来设计,先创建最大线程,再添加进队列也是没问题的。
线程池先添加队列的设计目的:
线程池在创建新线程的时候,是要获取全局锁的,这个时候其它的就得阻塞,影响了整体效率。
主要原因:出于线程池创建,销毁开销大的考虑。
大白话说明线程池流程: 就好比一个企业里面有10个(core)正式工的名额,最多招10个正式工,要是任务超过正式工人数(task > core)的情况下,工厂领导(线程池)不是首先扩招工人,还是这10人,但是任务可以稍微积压一下,即先放到队列去(代价低)。10个正式工慢慢干,迟早会干完的,要是任务还在继续增加,超过正式工的加班忍耐极限了(队列满了),就的招外包帮忙了(注意是临时工)要是正式工加上外包还是不能完成任务,那新来的任务就会被领导拒绝了(线程池的拒绝策略)。
需知: 线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制。
原理: 在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停检查是否有任务需要被执行,如果有则直接执行。
也就是每个线程去调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。