Java八股文总结(二):https://blog.csdn.net/weixin_44780078/article/details/131796843
线程是一种系统资源,每创建一个线程都需要占用一定的内存(需分配栈内存),如果在高并发的情况下,一瞬间来了很多任务,每个任务都需要创建一个线程,这样务必会占用太多的资源,也可能会导致out of memory(内存溢出)的情况发生;为了避免这种情况,于是就引入了线程池,线程池和数据库连接池非常类似,可以把线程池看作是一个管理线程的容器,可以统一管理和维护线程,减少没有必要的开销。
上面也提到了什么是线程池,由于我们的计算机 cpu 数量有限,创建太多的线程会导致有大部分线程会因为得不到 cpu 的调度而导致阻塞,cpu 进行过多的线程的上下文切换(新建—就绪—运行—阻塞—死亡)也会严重影响性能。而线程池是提前创建一批线程,让这些线程一直处于运行状态,并且可以得到重复的利用,这样就避免了过多的线程去新建或者上下文切换所造成的耗时。
一般在实际的开发中,是禁止自己去 new 线程的,假如在一个线程中使用到了 new 线程,一旦被有意的人发现这个 bug 后,发起恶意的攻击,就会创建太多的线程导致服务器 cpu 飙高宕机。因此可以说在实际的开发环境中必须使用线程池维护和创建线程。
// Executors.newCachedThreadPool(); 可缓存线程池
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>(),
threadFactory);
}
// Executors.newFixedThreadPool( int n ); 可定长度线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
// Executors.newScheduledThreadPool( int corePoolSize ); // 可定时线程池
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
// Executors.newSingleThreadExecutor(); // 单例线程池
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
线程池的创建方式 Executors中提供了上述四种,都是 jdk 中已经封装好的,它们的底层都是基于 ThreadPoolExecutor 构造函数创建线程池,因为 ThreadPoolExecutor 底层都是采用无界队列封装的,可能会造成线程溢出问题。因此非常遗憾阿里巴巴开发手册里面都不推荐这四种方式创建线程池。
public static ExecutorService newWorkStealingPool() {
return new ForkJoinPool
(Runtime.getRuntime().availableProcessors(),
ForkJoinPool.defaultForkJoinWorkerThreadFactory,
null, true);
}
这个线程池和上述的四种线程池有区别,它的底层不再是 ThreadPoolExecutor,而是通过 ForkJoinPool 去创建线程。相比之下,这个线程池的优点就是,没有了上述四种方式的无界队列,所以也就不会有内存溢出的情况发生。
先补充一个知识点:并发队列(LinkedBlockingDeque)
public static void main(String[] args) {
/**
* 这个LinkedBlockingDeque是一个无界队列
* 无界与有界的区别:
* 无界:对容量没有限制
* 有界:对容量有限制
*/
LinkedBlockingDeque<String> strings = new LinkedBlockingDeque<>();
strings.add("张三");
strings.add("李四");
strings.add("王五");
System.out.println(strings.poll());
System.out.println(strings.poll());
System.out.println(strings.poll());
System.out.println(strings.poll());
}
poll() : 从队列中移除一个元素,先插入的元素会先移除。当没有元素时调用 poll() 方法为 null。
并发知识点演示结束,开始手写线程池:
MyExecutors.java
public class MyExecutors {
private List<workThread> workThreadList; // 实现创建好的一批线程
private BlockingDeque<Runnable> runnableDeque; // 并发队列
private boolean isRun = true; // 运行状态
/**
* @param maxThreadCount 最大线程数
* @param queueSize 队列容量
*/
public MyExecutors(int maxThreadCount, int queueSize) {
// 1.限制队列容量
runnableDeque = new LinkedBlockingDeque<>(queueSize);
// 2.提前创建好固定的线程,一直处于运行状态
workThreadList = new ArrayList<>(maxThreadCount);
for (int i = 0; i < maxThreadCount; i++) {
new workThread().start();
}
}
// 工作线程一直处于运行状态
class workThread extends Thread {
@Override
public void run() {
while (isRun || runnableDeque.size() > 0) {
Runnable runnable = runnableDeque.poll(); // 并发队列中取出一个线程,执行
if (runnable != null) {
runnable.run();
}
}
}
}
public boolean execute(Runnable runnable) {
// 向队列中添加线程,当队列中满了后,就会添加失败
return runnableDeque.offer(runnable);
}
public static void main(String[] args) {
MyExecutors myExecutors = new MyExecutors(3, 6);
for (int i=0; i<10; i++) {
final int finalI = i;
myExecutors.execute(new Runnable() {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "," + finalI);
}
});
}
myExecutors.isRun = false;
}
}
分析上述代码,在MyExecutors() 初始化时,就创建了 3 个复用的线程,并且定义了一个容量为 6 的并发队列。
1、提交的线程任务数 ≤ 核心线程数,核心线程直接复用。
2、核心线程 < 提交的线程任务数 ≤ 最大线程数,如果队列容量未满,将线程任务缓存到队列中。
3、核心线程 < 提交的线程任务数 ≤ 最大线程数,如果队列容量已满,最多再创建(最大-核心)个线程,多余的线程拒绝。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
答:分情况,如果是核心线程数,则会一直保持运行状态,如果是最大线程数创建的非核心线程,则不会,并且有一个默认存活时间,超过存活时间就会销毁。
例如配置核心线程数 corePoolSize 为 2,最大线程数 maximumPoolSize 为 5,我们可以通过配置超出corePoolSize 核心线程数后创建的线程的存活时间,假如设为60秒,在60秒内非核心线程没有任务执行,则会进行销毁。
如果队列满了,且任务总数大于最大线程数则当前线程走拒绝策略。
线程池有如下拒绝策略:
因为 Executors 底层是采用 ThreadPoolExecutor 构造函数来创建线程池,ThreadPoolExecutor 中有一个参数名为 LinkedBlockingQueue 的无界队列用来存放线程任务,这个无界队列的上限是 Integer.MAX_VALUE,由于上限太大,如果不断的存放线程任务就会不断的占用内存,最终可能会导致内存溢出。
悲观锁:悲观锁认为线程安全问题一定会发生。
乐观锁:乐观锁认为线程安全问题不一定会发生。
通俗的说就是,CAS算法,有一个预设值、修改数据的时候,会传入一个标记值和一个更新值,如果预设值和标记值相同,则把预设值改为更新值,否则不做任何操作。
CAS:Compare and Swap的简称,翻译成比较并交换。通俗的说就是,CAS算法,有一个预设值、修改数据的时候,会传入一个标记值和一个更新值,如果预设值和标记值相同,则把预设值改为更新值,否则不做任何操作。
public class AtomicTryLock {
private AtomicInteger cas = new AtomicInteger(0);
private Thread lockCurrentThread; // 记录锁被哪个线程所持有
/**
* 获取锁
* @return
*/
public boolean tryLock() {
boolean result = cas.compareAndSet(0, 1);
if (result) {
lockCurrentThread = Thread.currentThread();
}
return result;
}
/**
* 释放锁
* @return
*/
public boolean unLock() {
if (lockCurrentThread != Thread.currentThread()) {
return false;
}
return cas.compareAndSet(1, 0);
}
public static void main(String[] args) {
AtomicTryLock atomicTryLock = new AtomicTryLock();
IntStream.range(1, 10).forEach((i) -> new Thread(() -> {
try {
boolean result = atomicTryLock.tryLock();
if (result) {
atomicTryLock.lockCurrentThread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + ",获取锁成功~");
} else {
System.out.println(Thread.currentThread().getName() + ",获取锁失败~");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (atomicTryLock != null) {
atomicTryLock.unLock();
}
}
}).start());
}
}
CAS 判断原理:把内存中的预设值和传入的标记值做判断,看是否相等,如果相等,就把内存值替换成更新值。
ABA 问题:假如预设值是A,第一次修改为B,再接着修改为A,这样绕了一圈还是修改成了原先的值,假如我们再把 A 修改成 C 还是能修改成功。这就导致原本值已经发生了变化,但是修改判断时好像又没有变化,这就出现了 ABA 问题。
解决:此处引入一个新的方法:AtomicStampedReference。
AtomicStampedReference:只要有其它线程操作过共享变量,那么自己的 cas 就算失败,这时,仅比较值是不够的,需要再加一个版本号,即有线程操作过共享变量,就让版本号 +1。
/**
* 预设初始值: "A"
* 预设版本号:0,也可以设置其他数,规则是自定义的
*/
static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A",0);
public static void main(String[] args) {
String prev = ref.getReference();
int stamp = ref.getStamp();
log.debug("版本号为:{}",stamp);
other();
sleep(1000);
log.debug("other方法执行结束,版本号:",stamp);
/**
* 传入的值: prev
* 想要更新的值:"C"
* 带入的版本号:stamp
* 修改成功后修改的预设标记值:false
*
* 判断传入的prev是否等于预先设置的初始值,并且判断版本号是否等于初始的版本号,是则修改,修改后还把版本号+1,否则不予修改。
*/
log.debug("change A->C: {}", ref.compareAndSet(prev,"C",stamp,stamp+1));
}
public static void other() {
new Thread(() -> {
int stamp = ref.getStamp();
log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B", stamp, stamp+1));
},"线程t1").start();
sleep(500);
new Thread(() -> {
int stamp = ref.getStamp();
log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A", stamp, stamp+1));
},"线程t2").start();
}
此处再引入一个方法:AtomicMarkableReference
AtomicMarkableReference:相对于AtomicStampedReference,AtomicMarkableReference只记录一个boolean值,假如初始值传true,有其他线程操作过,就改为false,这样就不需要记录版本号了。
* 预设初始值: "A"
* 预设标记值:true,也可以为false,规则是自定义的
*/
static AtomicMarkableReference<String> ref = new AtomicMarkableReference<>( "A",true);
public static void main(String[] args) {
String prev = ref.getReference();
other();
sleep(1000);
/**
* 传入的值: prev
* 想要更新的值:"C"
* 带入的预设标记值:true
* 修改成功后修改的预设标记值:false
*
* 判断传入的prev是否等于预先设置的初始值,并且判断标记是否为true,是则修改,修改后还把标记改为fasle,否则不予修改。
*/
log.debug("change A->C: {}", ref.compareAndSet(prev,"C",true,false));
}
public static void other() {
new Thread(() -> {
log.debug("change A->B {}", ref.compareAndSet(ref.getReference(), "B", true,false));
},"线程t1").start();
sleep(500);
new Thread(() -> {
log.debug("change B->A {}", ref.compareAndSet(ref.getReference(), "A", true,false));
},"线程t2").start();
}
LockSupport 是 jdk 中用于阻塞的原语,AQS: AbstractQueuedSynchronizer 就是通过调用 LockSupport .park() 和 LockSupport .unpark() 实现线程的阻塞和解除阻塞的。
LockSupport.park():让线程阻塞;
LockSupport.unpark(线程t):唤醒阻塞的线程 t;
Lock 锁和 synchronized 功能是一样的,明显的区别就是 Lock 锁底层是 c++ 语言写的,synchronized 底层是 java 写的。
Lock 底层基于 AQS + CAS + LockSupport 锁实现。
ThreadLocal 提供了线程本地变量,它可以保证访问到的变量属于当前线程,每个线程都保存有一个变量副本,每个线程的变量都不同。ThreadLocal 相当于提供了一种线程隔离,将变量与线程绑定。Threadlocal 适用于在多线程的情况下,可以实现传递数据,实现线程隔离。
Threadlocal 基本API:
- New Threadlocal():创建 Threadlocal;
- set(): 设置当前线程绑定的局部变量;
- get(): 获取当前线程绑定的局部变量;
- remove():移除当前线程绑定的变量;
Synchronized 与 Threadlocal 都可以实现多线程访问,保证线程安全的问题。
- Synchronized 当多个线程竞争到同一个资源的时候,最终只能有一个线程访问,采用时间换空间的方式,保证线程安全。
- Threadlocal 在每个线程中都自己独立的局部变量,空间换时间,线程之间相互隔离,相比来说 Threadlocal 效率比 Synchronized 更高。
前言:
在 JDK1.2 以前的版本中,当一个对象不被任何变量引用,那么程序就无法再使用这个对象。也就是说,只有对象处于可触及状态,程序才能使用它。这就像在商店购买了某样物品后,如果有用就一直保留它,否则就把它扔到垃圾箱,由清洁工人收走。一般说来,如果物品已经被扔到垃圾箱,想再把它捡回来使用就不可能了。
但有时候情况并不这么简单,可能会遇到可有可无的"鸡肋"物品。这种物品现在已经无用了,保留它会占空间,但是立刻扔掉它也不划算,因为也许将来还会派用场。对于这样的可有可无的物品:如果家里空间足够,就先把它保留在家里,如果家里空间不够,即使把家里所有的垃圾清除,还是无法容纳那些必不可少的生活用品,那么再扔掉这些可有可无的物品。
在Java中,虽然不需要程序员手动去管理对象的生命周期,但是如果希望某些对象具备一定的生命周期的话(比如内存不足时 JVM 就会自动回收某些对象从而避免 OutOfMemory 的错误)就需要用到软引用和弱引用了。
从Java SE2 开始,就提供了四种类型的引用:强引用、软引用、弱引用和虚引用。Java中提供这四种引用类型主要有两个目的:第一是可以让程序员通过代码的方式决定某些对象的生命周期;第二是有利于 JVM 进行垃圾回收。
强引用:我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。比如下面这段代码中的 object 和 str 都是强引用:
Object object = new Object();
String str = "StrongReference";
如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。
public class StrongReference {
public static void main(String[] args) {
new StrongReference().method1();
}
public void method1(){
Object object = new Object();
Object[] objArr = new Object[Integer.MAX_VALUE];
}
}
结果:
如果想中断强引用和某个对象之间的关联,可以显示地将引用赋值为 null,这样一来的话,JVM 在合适的时间就会回收该对象。
软引用:软引用在 Java 中用 java.lang.ref.SoftReference 类来表示,当系统内存充足的时候,不会被回收;当系统内存不足时,它会被回收,软引用通常用在对内存敏感的程序中,比如高速缓存就用到软引用,内存够用时就保留,不够时就回收。
String str = "test";
SoftReference<String> stringSoftReference = new SoftReference<>(str);
弱引用:弱引用也是用来描述非必需对象的,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。在java中,用java.lang.ref.WeakReference类来表示。
String str = "test";
WeakReference<String> weakReference = new WeakReference(str);
弱引用与软引用的区别在于:弱引用的对象拥有更短暂的生命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。所以被软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在 JVM 进行垃圾回收时总会被回收。
什么是内存泄漏问题:内存泄漏表示程序员申请了内存,但是该内存一直无法释放。
内存溢出问题:申请内存时,发现申请内存不足,就会报错内存溢出问题。
演示内存泄漏问题:
public static void main(String[] args) {
ThreadLocal<String> stringThreadLocal = new ThreadLocal<>();
stringThreadLocal.set("zhangsan");
stringThreadLocal = null;
Thread thread = Thread.currentThread();
System.out.println(thread);
}
对此代码进行打断点调试:即使把 stringThreadLocal 赋值为 null,在threadLocals底下,发现缓存的字符串"zhangsan"也依旧存在。
ThreadLocal 内存泄漏大致为这样的:
ThreadLocal 本身不存储数据,它使用了线程中的 threadLocals 的属性,这个 threadLocals 是一个在 ThreadLocal 中定义的 ThreadLocalMap 对象,当调用 ThreadLocal 的 set 方法时,就把ThreadLocal 自身的引用 this 当作 key,用户传入的值当作 value 存到线程的 ThreadLocalMap 中,在 ThreadLocalMap 中有一个 Entry 对象,它的 key 就是 ThreadLocal类型,value 是 Object 类型。由于 ThreadLocal 是弱引用的,所以当外部没有强引用指向它的时候,它就会被gc回收,导致Entry的key为空,如果value没有被外部强引用的话,那么value就永远不会被访问,由于value是强引用而非弱引用,所以value不会被gc回收,所以就出现了内存泄漏的问题发生。
避免内存泄漏有如下两种方法:
- 每次使用完 ThreadLocal 后调用 remove() 方法清除数据。
- 将ThreadLocal 变量尽可能定义成 static final,这样就可以避免频繁去创建 ThreadLocal 实例
在客户端 http 请求服务端时,如果业务较复杂,服务端由于要处理过多的业务逻辑,因此使用同步调用的话会导致服务端响应时间过长,使用户的体验感降低。因此使用多线程或者 MQ 可以异步实现服务调用,从而提升服务端的响应效率。
eg1:比如网络平台的借钱,首先要填写自己的个人信息,平台根据你的信息评估你的借钱额度,比如有查询你的征信,是否信用良好,查询你的名下是否有公司、房产、车产等信息。这些都是调用其他平台的接口,如果使用同步的方式,会非常耗时,一般的网络平台都是你填写完毕一提交就会得出额度,响应时间非常快。
eg2:平台注册会员,流程图大致如下:
使用同步操作的话大致是7秒。对于步骤2和步骤3,都存在不确定因素,对于发送短信,可能耗时3秒,也可能耗时更多,因此采用异步的方式后就不用考虑发送短信的耗时。
总之将执行比较耗时的代码操作,交给 MQ 异步实现接口。
生产背景:生产者投递消息的速率与消费者消费的速率完全不匹配。生产者投递消息的速率 > 消费者消费的速率,导致消息会
堆积在 mq 服务器中,没有及时的被消费者消费所以会产生消息堆积的问题。
需要注意的是,rabbitmq 消费者如果消费成功的话,消息会被立即删除,kafka 或者 rocketmq 消息消费如果成功的话,消息则不会被立即删除。
解决办法:
要保证消息不丢失,需要保证生产者投递消息到 mq 服务器必须成功,也必须保证消费者从 mq 服务器消费消息成功。
MQ 服务器集群或者 MQ 采用分区模型架构存放消息,每个分区对应一个消费者消费消息来解决消息顺序一致性问题。
核心办法:消息一定要投递到同一个 mq、同一个分区模型、最终被同一个消费者。消费根据消息 key % 分区模型总数
1、大多数的项目是不需要保证 mq 消息顺序一致性的问题,只有在一些特定的场景可能会需要,比如 MySQL 与 Redis 实现异步同步数据。
2、所有消息需要投递到同一个 mq 服务器,同一个分区模型中存放,最终被同一个消费者消费,核心原理是设定相同的消息 key,根据相同的消息 key 计算 hash 存放在同一个分区中。
3、如果保证了消息顺序一致性有可能降低我们消费者消费的速率。
在讲解之前,先做以下铺垫:java中有哪些数据类型?以及他们的存储位置?
之所以做出上述铺垫,是因为在java中,== 比较的内容和数据所处的位置相关。
1.对于==,如果是八大基本数据类型,比较的是常量池中的值是否相等;如果是引用数据类型(类、接口、数组),比较的是对象的引用地址(每new一个对象,其引用地址都是不一样的)。
2.对于eqlals()方法,常用于比较对象是否相等(String也属于对象)。Object中默认的equals()方法比较的是两个对象的地址是否相等,当然,我们也可以重写这个equals()方法,java中equals方法也允许我们根据需要自定义比较方法。
讲解之前,先思考,哪些场景下需要重写hashCode()和equals()方法?
在我们使用HashMap时,如果key为一个对象,如何保证这个对象的唯一性?同样的问题,在使用HashSet时,由于set集合的无序不重复特性,如何保证存入对象的唯一性?
此处以HashMap举例,首先我们需要清楚HashMap的数据结构,HashMap底层采用了数组+链表的结构,其中jdk1.8以后还加入了红黑树。简图如下:
由上图得出,HashMap其实就是一个链表数组,我们的数据实际是存放在链表上,在我们使用map.put()方法时,实际上是先采用hashCode()方法得出哈希值,再通过哈希值计算出下标位置(就是存储在数组哪个下标底下),当数组下标位置相同而value不同时,这种情况叫做“哈希冲突”,解决哈希冲突的方法有很多,此处不一一赘述。
言归正传,回到最初的问题,我们为什么要重写hashCode()和equals()方法?
通过一个案例来说明,我们创建两个Person对象,两个对象id都是1,名字都是张三。存入map后打印两个对象key的value。
// 假如现有一个Person类
Person p1 = new Person("1","张三");
Person p2 = new Person("1","张三");
// 以person为key存入map
Map<Person, Object> map = new HashMap<>();
map.put(p1, "我是person1");
map.put(p2, "我是person2");
System.out.println(map.get(p1)); // 打印什么? 我是person1
System.out.println(map.get(p2)); // 打印什么? 我是person2
此处思考,为啥两个key打印的结果不同?按理说两个对象相同,打印结果也应该相同才对。在上面的==与equals问题中也提到了一点:Object中默认的equals()方法比较的是两个对象的地址是否相等,而默认的hashCode()方法返回的是对象的内存地址转换成的一个int整数,实际上指的也是内存,两个方法都可以理解为比较的是内存地址。
// Object中默认的hashCode()和equals()
public native int hashCode();
public boolean equals(Object obj) {
return (this == obj);
}
因此就算p1、p2的id和name都相同,也是两个不同的对象,要想保证这个person对象的唯一性,就只能重写hashCode()和equals()方法。重写过后如果新建了多个对象,这些对象的属性都一致的话就会判断为同一个对象。
续:Java八股文总结(二):https://blog.csdn.net/weixin_44780078/article/details/131796843