2024.11.07 周四
行程总结:上午睡觉、下午学习(写了点课程的作业)、晚上学习
知识总结:进程线程相关知识、锁相关知识、lambda(函数式接口)语法
八股
进程 & 线程
Java 底层会调用 pthread_create 来创建线程,所以本质上 java 程序创建的线程,就是和操作系统线程是一样的,是 1 对 1 的线程模型。
线程和进程的区别?
线程是调度的基本单位,而进程则是资源拥有的基本单位。
- 当进程只有一个线程时,可以认为进程就等于线程;
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的;
线程的创建方式有哪些?
1.继承Thread类
自定义类继承java.lang.Thread
类,重写其run()
方法,run()
方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()
方法启动线程。
- 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程
- 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类
class MyThread extends Thread{
@Overrie
publci void run(){
// 线程执行的代码
}
}
public static void main(String[] args){
MyThread t = new MyThread();
t.start();
}
2.实现Runnable接口
实现java.lang.Runnable
接口。实现Runnable
接口需要重写run()
方法,然后将此Runnable对象
作为参数传递给Thread类的构造器
,创建Thread对象
后调用其start()
方法启动线程。
- 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,
可以多个线程共享同一个目标对象
,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。 - 缺点:编程稍微复杂,如果需要访问当前线程,必须使用
Thread.currentThread()
方法。
class MyRunnable implements Runnable {
@Override
public void run(){
// 线程执行的代码
}
}
public static void main(String[] args){
Thread t = new Thread(new MyRunnable());
t.start();
}
3.实现Callable接口与FutureTask
java.util.concurrent.Callable
接口类似于Runnable,但Callable的call()
方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask
,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口
。
- 缺点:编程稍微复杂,如果需要访问当前线程,必须调用
Thread.currentThread()
方法。 - 优点:线程只是实现Runnable或实现Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。
class MyCallable implements Callable<Integer>{
@Override
public Integer call() throws Exception{
// 线程执行的代码,这里要返回一个整形结果
return 1;
}
}
public static void main(String[] args){
MyCallable task = new MyCallable();
FutureTask<Integer> futureTask = new FutureTask<>(task);
Thread t = new Thread(futureTask);
t.start();
}
4.使用线程池(Executor框架)
从Java 5开始引入的java.util.concurrent.ExecutorService
和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类
的静态方法创建不同类型的线程池。
- 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。
- 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐
class Task implements Runnable{
@Override
public void run(){
// 线程执行的代码
}
}
public static void main(String[] args){
// 创建固定大小的线程池
ExecutorService executor = Executors.newFixedThreadPool(10);
// 通过循环体不断将实现Runnable接口的task加入线程池
for (int i = 0; i < 100; i++){
executor.submit(new Task()); // 提交任务到线程池执行
}
executor.shutdown(); // 关闭线程池
}
锁
互斥锁与自旋锁
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:
-
互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
-
互斥锁加锁失败后,线程会释放 CPU ,给其他线程(
线程切换
)。
-
自旋锁加锁失败后,线程会
忙等待
,直到它拿到锁。
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
一般加锁的过程,包含两个步骤:
- 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
- 第二步,将锁设置为当前线程持有;
读写锁
由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
根据实现的不同,读写锁可以分为「读优先锁」和「写优先锁」。
-
「读优先锁」期望的是,读锁能被更多的线程持有,以便提高读线程的并发性
-
「写优先锁」是优先服务写线程
读优先锁造成了写线程「饥饿」的现象:如果一直有读线程获取读锁,那么写线程将永远获取不到写锁。
写优先锁造成了读线程「饥饿」的现象:但是如果一直有写线程获取写锁,读线程也会被「饿死」。 -
「公平读写锁」:不偏袒任何一方。
公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。
乐观锁与悲观锁
悲观锁做事比较悲观,认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
- 悲观锁是修改共享数据前,都要先加锁,防止竞争。
乐观锁做事比较乐观,假定冲突的概率很低,先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
- 乐观锁是先修改同步资源,再验证有没有发生冲突。
在线文档可以同时多人编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
服务端要怎么验证是否冲突的方案如下:
- 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
- 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号不一致则提交失败,如果版本号一致则修改成功,然后服务端版本号更新到最新的版本号。
算法
121.买卖股票的最佳时机(简单题)
169.多数元素(简单题)
项目
买了个项目(给报销的)准备不学苍穹外卖,学其他的直接准备简历啦
ThreadLocal
ThreadLocal
类主要解决的就是让每个线程绑定自己的值,可以将ThreadLocal
类形象的比喻成存放数据的盒子,盒子中可以存储每个线程的私有数据。
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本,这也是ThreadLocal
变量名的由来。他们可以使用get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
如何使用ThreadLocal?
以下两段代码等价
import java.text.SimpleDateFormat;
import java.util.Random;
// 定义ThreadLocalExample类实现Runnable接口
public class ThreadLocalExample implements Runnable{
// SimpleDateFormat 不是线程安全的,所以每个线程都要有自己独立的副本
/**
ThreadLocal类中提供一个静态方法withInitial()
其接受一个Supplier函数式接口作为参数
该Supplier在每次通过lambda表达式创建一个新的SimpleDateFormat对象
这段代码确保每个线程都有自己独立的SimpleDateFormat实例
*/
private static final ThreadLocal<SimpleDateFormat> formatter = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyyMMdd HHmm"));
// ThreadLocalExample类中定义了一个main方法
public static void main(String[] args) throws InterruptedException {
// 在main中新建一个ThreadLocalExample类
ThreadLocalExample obj = new ThreadLocalExample();
/**
循环体内部,每次循环创建一个新的线程 t
将 obj 作为目标传递给线程
将循环变量 i 转换成字符串作为线程的名字
*/
for(int i=0 ; i<10; i++){
Thread t = new Thread(obj, ""+i);
// 线程在启动前休眠一个随机时间(0~999 ms)
Thread.sleep(new Random().nextInt(1000));
t.start();
}
}
// 重写Runnable接口的run()方法
@Override
public void run() {
// 打印当前线程的name和默认的日期格式模式
System.out.println("Thread Name= "+Thread.currentThread().getName()+" default Formatter = "+formatter.get().toPattern());
try {
Thread.sleep(new Random().nextInt(1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
//formatter pattern is changed here by thread, but it won't reflect to other threads
// 将formatter的值设置成一个新的SimpleDateFormat实例,其使用默认的日期时间格式
formatter.set(new SimpleDateFormat());
// 再次打印当前线程的名称和修改后的日期格式模式。
System.out.println("Thread Name= "+Thread.currentThread().getName()+" formatter = "+formatter.get().toPattern());
}
}
// 直接在ThreadLocal的实例化中重写其initialValue方法
private static final ThreadLocal<SimpleDateFormat> formatter = new ThreadLocal<SimpleDateFormat>(){
@Override
protected SimpleDateFormat initialValue(){
return new SimpleDateFormat("yyyyMMdd HHmm");
}
}
ThreadLocal原理了解吗?
从Thread
类源代码入手
ThreadLocalMap
可以理解为ThreadLocal
类实现的定制化的 HashMap。默认情况下这两个变量都是 null,只有当前线程调用ThreadLocal
类的 set
或get
方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap
类对应的 get()
、set()
方法。
public class Thread implements Runnable {
// ......
// 与此线程有关的ThreadLocal值,由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
// 与此线程有关的InheritableThreadLocal值,由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}
ThreadLocal
类的set()
方法
最终的变量是放在了当前线程的ThreadLocalMap
中,并不是存在ThreadLocal
上,ThreadLocal
可以理解为只是ThreadLocalMap
的封装,传递了变量值。
每个Thread
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为key,Object对象为value的键值对。
public void set(T value) {
// 获取当前请求的线程
Thread t = Thread.currentThread();
// 取出Thread类内部的threadLocals变量(哈希表结构)
ThreadLocalMap map = getMap(t);
if (map != null)
// 将需要存储的值放入到这个哈希表中
map.set(this, value);
else
createMap(t, value);
}
ThreadLocalMap getMap(Thread t){
return t.threadLocals;
}
ThreadLocal内存泄露问题是怎么导致的?
ThreadLocalMap
中使用的key为ThreadLocal
的弱引用,而value是强引用。所以如果ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key会被清理掉,而value不会被清理掉。
因此,ThreadLocalMap
中就会出现key为null的Entry。假如不做任何措施,value永远无法被GC回收,这个时候就可能会产生内存泄漏。ThreadLocalMap
实现中已经考虑了该种情况,在调用 set()
、get()
、remove()
方法的时候,会清理掉 key 为 null 的记录。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
弱引用介绍:
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它
所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,
因此不一定会很快发现那些只具有弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java
虚拟机就会把这个弱引用加入到与之关联的引用队列中