总结
虽然我个人也经常自嘲,十年之后要去成为外卖专员,但实际上依靠自身的努力,是能够减少三十五岁之后的焦虑的,毕竟好的架构师并不多。
架构师,是我们大部分技术人的职业目标,一名好的架构师来源于机遇(公司)、个人努力(吃得苦、肯钻研)、天分(真的热爱)的三者协作的结果,实践+机遇+努力才能助你成为优秀的架构师。
如果你也想成为一名好的架构师,那或许这份Java成长笔记你需要阅读阅读,希望能够对你的职业发展有所帮助。
ReentrantLock
ReentantLock 继承接口 Lock 并实现了接口中定义的方法,他是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
ReadWriteLock读写锁
**为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。
读锁:如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁
写锁:如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁
Java 中读写锁有个接口 java.util.concurrent.locks.ReadWriteLock ,也有具体的实现ReentrantReadWriteLock。**
是否锁同步资源:乐观锁、悲观锁
乐观锁:一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java 中的乐观锁基本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
悲观锁:一种悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java中的悲观锁就是synchronized;
线程是否阻塞:自旋锁
如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
线程自旋是需要消耗 CPU 的,说白了就是让 CPU在做无用功,如果一直获取不到锁,那线程也不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
自旋锁的优点:
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!
线程竞争时是否排队:公平锁、非公平锁
-
公平锁:锁的分配机制是公平的,加锁前检查是否有排队等待的线程,优先排队等待的线程,先来先得
-
非公平锁:加锁时不考虑排队等待问题,JVM 按随机、就近原则分配锁的机制则称为不公平锁,非公平锁实际执行的效率要远远超出公平锁(5-10倍),除非程序有特殊需要,否则最常用非公平锁的分配机制。
线程中的多个流程是否可以获取同一把锁:可重入锁(递归锁)
可重入锁,也叫做递归锁,指的是同一线程外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。在 JAVA 环境下 ReentrantLock 和 synchronized 都是可重入锁。
多线程之间能不能共享同一把锁:共享锁和排他锁
-
共享锁:共享锁则允许多个线程同时获取锁,并发访问 共享资源,如:ReadWriteLock。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。
-
排他锁:每次只能有一个线程能持有锁,ReentrantLock 就是以独占方式实现的互斥锁。独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。
锁状态:无锁、偏向锁、轻量级锁、重量级锁
锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。
**锁升级:随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,
也就是说只能从低到高升级,不会出现锁的降级)。**
-
无锁没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
-
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价。在大多数情况下,锁总是由同一线程多次获得,不存在多线程竞争,所以出现了偏向锁。其目标就是在只有一个线程执行同步代码块时能够提高性能
-
轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。
-
重量级锁:升级为重量级锁时,锁标志的状态值变为“10”,此时Mark Word中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。
死锁
两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去!
如何查看线程死锁:通过jstack命令
进行查看,jstack中会显示发生死锁的线程
数据库中查看死锁:
查看是否有表锁:
show OPEN TABLES where In_use > 0;
查询进程:
show processlist;
查看正在锁的事务:
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS;
查看等待锁的事务:
SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCKS_WAITS;
守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作。(GC垃圾回收线程就是一个经典的守护线程)
应用场景:为其他线程提供服务支持、需要正常且立刻关闭某个线程时
守护线程不能用于访问固有资源,比如读写操作或计算逻辑,因为它在任何时候甚至在一个操作的中间发生中断。
继承 Thread 类,重写run方法:
public class MyThread extends Thread {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " run()方法正在执行…");
}
}
实现 Runnable 接口,实现run方法:
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " run()方法执行中…");
}
}
实现 Callable 接口,实现call方法。通过FutureTask创建一个线程,获取到线程执行的返回值:
public class MyCallable implements Callable {
@Override
public Integer call() {
System.out.println(Thread.currentThread().getName() + " call()方法执行 中…");
return 1;
}
}
使用 Executors 工具类创建线程池,并开启线程。
-
Thread 是一个抽象类,只能被继承,而 Runable、Callable 是接口,需要实现接口中的方法。继承 Thread 重写run()方法,实现Runable接口需要实现run()方法,而Callable是需要实现call()方法。
-
Thread 和 Runable 没有返回值,Callable 有返回值,返回值可以被 Future 拿到。
-
实现 Runable 接口的类不能直接调用start()方法,需要 new 一个 Thread 并发该实现类放入 Thread,再通过新建的 Thread 实例来调用start()方法。实现 Callable 接口的类需要借助 FutureTask (将该实现类放入其中),再将 FutureTask 实例放入 Thread,再通过新建的 Thread 实例来调用start()方法。获取返回值只需要借助 FutureTask 实例调用get()方法即可!
什么是 FutureTask?
FutureTask 表示一个异步运算的任务。FutureTask 里面可以传入一个Callable 的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。一个 FutureTask 对象可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是Runnable 接口的实现类,所以 FutureTask 也可以放入线程池中。
通过调用 Thread 类的 start()方法来启动一个线程, 这时此线程是处于就绪状态, 并没有运行。这时无需等待 run 方法体代码执行完毕,可以直接继续执行下面的代码。
方法 run()称为线程体,它包含了要执行的这个线程的内容,线程就进入了运行状态,开始运行 run 函数当中的代码。 run 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。
run() 可以重复调用,而 start()只能调用一次。
为什么我们调用 start() 方法时会执行 run() 方法,为什么我们不能直接调用run() 方法?
如果直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。
线程通常有5种状态:新建、就绪、运行、阻塞和死亡状态
-
新建(new):新创建了一个线程对象。
-
就绪(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。
-
运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。
-
阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才 有机会再次被 CPU 调用以进入到运行状态。
阻塞的情况分三种:
① 等待阻塞(wait->等待对列):运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waittingqueue)中,使本线程进入到等待阻塞状态;
② 同步阻塞(lock->锁池):线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
③ 其他阻塞(sleep/join): 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
- 死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。
由上图可以看出:线程创建之后它将处于 new(新建) 状态,调⽤ start() ⽅法后开始运⾏,线程这时候处于 ready(可运⾏) 状态。可运⾏状态的线程获得了 CPU 时间⽚(timeslice)后就处于 running(运⾏) 状态。
-
线程等待wait():使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;只有等待另外线程的通知或被中断才会返回,wait 方法一般用在同步方法或同步代码块中
-
线程睡眠sleep():使一个正在运行的线程处于睡眠状态,但不会释放当前占有的锁。是一个静态方法,调用此方法要处理InterruptedException 异常;
-
等待其他线程终止 join():线程进入阻塞状态,马上释放cpu的执行权,但依然会保留cpu的执行资格,所以有可能cpu下次进行线程调度还会让这个线程获取到执行权继续执行
-
线程让步yield():使当前线程让出 CPU 执行时间片,与其他线程一起重新竞争 CPU 时间片,执行后线程进入阻塞状态,例如在线程B种调用线程A的join(),那线程B会进入到阻塞队列,直到线程A结束或中断线程。
-
线程唤醒notify():唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关;
-
线程全部唤醒notityAll():唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态;
两者都可以暂停线程的执行
-
类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
-
是否释放锁:sleep() 不释放锁;wait() 释放锁,并且会加入到等待队列中。
-
是否依赖synchronized关键字:sleep不依赖synchronized关键字,wait需要依赖synchronized关键字
-
用途不同:sleep 通常被用于休眠线程;wait 通常被用于线程间交互/通信,
-
用法不同:sleep() 方法执行完成后,不需要被唤醒,线程会自动苏醒,或者可以使用wait(longtimeout)超时后线程会自动苏醒。wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()或者 notifyAll() 方法。
巧妙地利用了时间片轮转的方式, CPU 给每个任务都服务一定的时间,然后把当前任务的状态保存下来,在加载下一任务的状态后,继续服务下一任务,任务的状态保存及再加载, 这段过程就叫做上下文切换。时间片轮转的方式使多个任务在同一颗 CPU 上执行变成了可能。
上下文:指某一时间点 CPU 寄存器和程序计数器的内容
几种发生上下文切换的情况:
-
主动让出 CPU,比如调用了 sleep(), wait() 等。
-
时间片用完,因为操作系统要防止一个线程或者进程长时间占用CPU导致其他线程或者进程饿死。
-
调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞,被终止或结束运行
-
通过共享内存或基于网络通信
-
如果是基于共享内存进行通信,则需要考虑并发问题,什么时候阻塞,什么时候唤醒
-
想Java中的wait()、notify()就是阻塞唤醒
-
通过网络就比较简单,通过网络连接将数据发送给对方,当然也要考虑到并发问题,处理方式就是加锁等方式。
ThreadLocal是Java中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据。
ThreadLocal底层是通过 TreadLocalMap来实现的,每个Thread对象中都存在一个 ThreadLocalMap,Map的key为 ThreadLocal对象,Map的 value为需要缓存的值 。
-
ThreadLocalMap由一个个 Entry对象构成 Entry继承自 WeakReference< Threadlocal<?>>,一个 Entry由 Threadlocal对象和 object构成,由此可见,Entry的key是 Threadlocal对象,并且是一个弱引用。当没指向key的强引用后,该key就会被垃圾收集器回收
-
当执行set方法时, Threadlocal首先会获取当前线程对象,然后获取当前线程的 ThreadLocalMap对象。再以当前 Threadlocal对象为key,将值存储进 Threadlocalmap对象中
-
get方法执行过程类似,Threadloca首先会获取当前线程对象,然后获取当前线程的 ThreadLocalMap对象。再以当前 ThreadLocal对象为key,获取对应的value
由于每条线程均含有各自私有的 ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。
使用场景:
-
在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束
-
线程间数据隔离
-
进行事务操作,用于存储线程事务信息
-
数据库连接,Session会话管理
Spring框架在事务开始时会给当前线程绑定一个 Jdbc Connection,在整个事务过程都是使用该线程定的connection来执行数据库操作,实现了事务的隔离性, spring框架里面就是用的 Threadlocal来实现这种隔离。
如果在线程池中使用 ThreadLocal会造成内存泄漏,因为当 ThreadLocal对象使用完之后,应该要把设置的key,value也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向 ThreadLocalMap, ThreadLocalMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏。
解决办法:
-
在使用了 ThreadLocal对象之后,手动调用ThreadLocal的 remove方法,手动清除Entry对象
-
将ThreadLocal变量定义成private static,这样就一直存在ThreadLocal的强引用,也就能保证任何时候都能将通过ThreadLocal的弱引用访问到Entry的value值,进而清除掉。
线程池做的工作主要是控制运行的线程的数量,处理过程中将任务放入队列,然后在线程创建后启动这些任务,如果线程数量超过了最大数量超出数量的线程排队等候,等其它线程执行完毕,再从队列中取出任务来执行。他的主要特点为:线程复用;控制最大并发数;管理线程。
重用存在的线程,减少对象创建销毁的开销,且提高了响应速度;有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执行、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能。
线程池类别:
-
newFixedThreadPool :一个定长线程池,可控制线程最大并发数。
-
newCachedThreadPool:一个可缓存线程池。
-
newSingleThreadExecutor:一个单线程化的线程池,用唯一的工作线程来执行任务。
-
newScheduledThreadPool:一个定长线程池,支持定时/周期性任务执行。
线程池尽量不要使用 Executors 去创建,而是通过 ThreadPoolExecutor的方式去创建,因为Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等如果使用不当,会造成资源耗尽问题。直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险。
主要参数:
-
corePoolSize:核心线程数,默认情况下创建的线程数,默认情况下核心线程会一直存活,是一个常驻线程。
-
maximumPoolSize:线程池维护线程的最大数量,超过将被阻塞!(当核心线程满,且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,才决定是否创建新线程)
-
keepAliveTime:非核心线程的闲置超时时间,超过这个时间就会被回收,直到线程数量等于 corePoolSize。
-
unit:指定 keepAliveTime 的单位,如 TimeUnit.SECONDS、TimeUnit.MILLISECONDS
-
workQueue:线程池中的任务队列,常用的是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue。
-
threadFactory:创建新线程时使用的工厂
-
handler:RejectedExecutionHandler 是一个接口且只有一个方法,线程池中的数量大于 maximumPoolSize,对拒绝任务的处理策略,默认有 4 种策略:AbortPolicy、CallerRunsPolicy、DiscardOldestPolicy、DiscardPolicy
AbortPolicy:中止策略。默认的拒绝策略,直接抛出 RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
DiscardPolicy:抛弃策略。什么都不做,直接抛弃被拒绝的任务。
DiscardOldestPolicy:抛弃最老策略。抛弃阻塞队列中最老的任务,相当于就是队列中下一个将要被执行的任务,然后重新提交被拒绝的任务。如果阻塞队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用。
分享
1、算法大厂——字节跳动面试题
2、2000页互联网Java面试题大全
3、高阶必备,算法学习
的处理代码。**
DiscardPolicy:抛弃策略。什么都不做,直接抛弃被拒绝的任务。
DiscardOldestPolicy:抛弃最老策略。抛弃阻塞队列中最老的任务,相当于就是队列中下一个将要被执行的任务,然后重新提交被拒绝的任务。如果阻塞队列是一个优先队列,那么“抛弃最旧的”策略将导致抛弃优先级最高的任务,因此最好不要将该策略和优先级队列放在一起使用。
分享
1、算法大厂——字节跳动面试题
[外链图片转存中…(img-c8mvINUH-1715573829381)]
2、2000页互联网Java面试题大全
[外链图片转存中…(img-8OyCQ50g-1715573829381)]
3、高阶必备,算法学习
[外链图片转存中…(img-L8hZPcKi-1715573829382)]