赞
踩
目录
6.Java中一个字符占多少个字节,扩展再问int、 long、double占多少字节?
10.String str="hello world"和String str=new String("hello world")的区别?(高频考点)
11.StringBuffer和StringBuilder的区别是什么?性能对比?如何鉴定线程安全?(高频考点)
12.StringBuffer 和 StringBuilder 底层怎么实现的?(高频考点)
13.switch支持哪些数据类型?支持long么?(高频考点)
15.final、finnally、finalize的区别是什么?
16.Jdk1.8/Jdk1.7都分别新增了哪些特性?其他版本呢?(高频考点)
17.简单说下Lambda表达式,其解决了什么,相比java7的处理优化了什么?
18.有人说“Lambda能让Java程序慢30倍”,你怎么看?
22.假设引用了一个第三方的jar 有个类和我自己写的代码类一样,那么在类加载机制过程中是如何处理的?(高频考点)
23.Java提供了哪些IO方式? NIO如何实现多路复用?
1.HashMap相关put操作,get操作等流程?(高频考点)
3.HashMap如果我想要让自己的Object作为K应该怎么办?
5.HashMap1.7与HashMap1.8的区别,从数据结构上、Hash值的计算上、链表数据的插入方法、内部Entry类的实现上分析?
7.Hash1.7是基于数组和链表实现的,为什么不用双链表?HashMap1.8中引入红黑树的原因是?为什么要用红黑树而不是平衡二叉树?(高频考点)
8.HashMap、HashTable、ConcurrentHashMap的原理与区别?
9.HashMap和ConcurrentHashMap区别(高频考点)
10. ConcurrentHashMap的数据结构(高频考点)
13.Collections.SynchronizedCollection方法实现原理是什么?
14.Array和ArrayList有什么区别?使用时注意事项有哪些?
15.常用的集合类有哪些?比如List如何排序(最好说下底层上的实现)?(高频考点)
16.ArrayList和LinkedList内部的实现大致是怎样的?他们之间的区别和各自适应的场景是什么?
4.Java线程的状态?细说一下BLOCKED和WAITING有什么区别?(高频考点)
7.Java程序中启动一个线程是用run()还是start()?
8.Thread的start方法调用两次会怎么样?Thread是如何保证start方法调用只有一次生效?(高频考点)
13.volatile与synchronized的区别是什么?volatile作用(高频考点)
15.Atomic类如何保证原子性(CAS操作)(高频考点)
16.AtomicInteger、AtomicBoolean这些类之所以在高并发时高效,共同的原因是?
17.关于 Atomic 类中的主要变量如下,其使用了 volatile 关键字进行修饰。你知道它在源码中的主要意义是?(高频考点)
19.比较和替换是两个动作,CAS 是如何保证这两个操作的原子性呢?
21.无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁,解释锁升级?(高频考点)
22.乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
24.为什么读多写少的情况,就适合使用乐观锁呢?悲观锁在读多写少的情况下,不也是有很少的冲突吗?(高频考点)
26.用java 代码实现一个死锁用例,说说怎么解决死锁问题?回到用例代码下,如何解决死锁问题呢?(高频考点)
29.ReentrantLock底层公平锁和非公平锁的原理(高频考点)
32.除了用Object.wait和Object.notifyAll来实现线程间的交互外,你还会常用哪些来实现?
34.ThreadLocal为什么要使用弱引用和内存泄露问题(高频考点)
35.ThreadLocal怎么解决内存泄露的问题?(高频考点)
39.核心线程池ThreadPoolExecutor的参数/常见线程池的创建参数是什么样的?(高频考点)
41.实现一个自定义的ThreadFactory的作用通常是?
43.ThreadPoolExecutor的工作流程(高频考点)
44.ScheduledThreadPoolExecutor中的使用的是什么队列?内部如何实现任务排序的?
45.线程池的运行逻辑,FixedThreadPool、CachedThreadPool的原理(高频考点)
46.用Executors.newCachedThreadPool创建的线程池,在运行的过程中有可能产生的风险是?
47.阻塞队列ArrayBlockingQueue、LinkedBlockingQueue分析(高频考点)
48.请合理的使用Queue来实现一个高并发的生产/消费的场景,给些核心的代码片段。
50.JUC下的常见类的使用,take、poll的区别,put、offer的区别?
51.Future原理,其局限性是什么?并说说CompletableFuture核心原理?
52.你是否了解fork/join(基本思想)?在工作中是如何使用的?说说他们的优势是什么?(高频考点)
53.Java线程池的调优经验有哪些?(线程池的合理配置)(高频考点)
54.一个请求中,计算操作需要50ms,db操作需要100ms,对于一台8核的机器来说,如果要求cpu利用率达到100%,如何设置线程数?(高频考点)
55.如果系统中不同的请求对应的cpu时间和io时间都不同,那怎么设置线程数量?(高频考点)
56.线程池核心数20,最大600,阻塞队列200,当QPS200(注意是qps)的时候,请求是调第三方阻塞超时,请问怎么提高它的吞吐量(注意不能加机器)?(高频考点)
57.当前线程池是200,线程单次处理请求20ms,那么理论上单节点的qps 是多少呢?
58.多线程对Long数据进行加和会存在什么问题?如何解决?
2.后台服务出现明显“变慢”,谈谈你的诊断思路?(高频考点)
4.在Java程序运行阶段,可以用什么命令行工具来查看当前Java程序的一些启动参数值,例如Heap Size等。
5.用什么命令行工具可以查看运行的Java程序的GC状况,请具体写出命令行格式。(高频考点)
6.用什么工具,可以在Java程序运行的情况下跟踪某个方法的执行时间,请求参数信息等,并请解释下工具实现的原理。
7.当一个Java程序接收请求,很长时间都没响应的话,通常你会怎么去排查这种问题?
干货分享,感谢您的阅读!背景高频面试题基本总结回顾(含笔试高频算法整理)
针对人员:
1.全部人员都适用;
2.正式员工:针对应届+工作1-3年的面试者,本部分可部分考察;
3.外包员工:本部分一般加大比重。
数据类型
对象和存储
自动装箱和拆箱
空值处理
性能
在实际开发中,通常优先使用基本类型,只有在需要对象特性,例如泛型或集合中,才会使用对应的包装类。自动装箱和拆箱的特性使得基本类型和包装类之间的转换变得更加方便。
实例方法是与类的实例相关联的,需要先创建类的实例才能调用;而静态方法则不依赖于类的实例,可以直接通过类名来调用。两者在访问权限、内存分配、重写和多态性等方面也有所不同。
特点 | 实例方法 | 静态方法 |
---|---|---|
调用方式 | 通过类的实例调用 | 直接通过类名调用 |
方法访问权限 | 可以访问实例变量和实例方法 | 不能直接访问实例变量和实例方法 |
内存分配 | 每次调用会分配内存来存储实例变量 | 不会分配内存来存储实例变量 |
重写与隐藏 | 可以被子类重写 | 不能被子类重写,但可以被子类隐藏 |
多态性 | 受对象实际类型影响 | 在编译时就确定了调用的方法 |
在Java中,变量存储的是引用而不是直接的内存地址。理解Java中变量指向的是引用而不是地址:
因此,虽然在语义上可以说Java的变量指向对象的内存地址,但更准确地说,Java的变量存储的是引用,用于访问对象。这种引用的使用方式使得Java具有更高层次的内存管理和安全性,同时提供了更好的抽象和封装性。
Object是所有类的父类,任何类都默认继承Object。Object类到底实现了哪些方法?
clone方法:保护方法,实现对象的浅复制,只有实现了Cloneable接口才可以调用该方法,否则抛出CloneNotSupportedException异常。
getClass方法:final方法,获得运行时类型。
toString方法:该方法用得比较多,一般子类都有覆盖。
finalize方法:该方法用于释放资源。因为无法确定该方法什么时候被调用,很少使用。
equals方法:该方法是非常重要的一个方法。一般equals和==是不一样的,但是在Object中两者是一样的。子类一般都要重写这个方法。
hashCode方法:该方法用于哈希查找,重写了equals方法一般都要重写hashCode方法。这个方法在一些具有哈希功能的Collection中用到。一般必须满足obj1.equals(obj2)==true。可以推出obj1.hashCode()==obj2.hashCode(),但是hashCode相等不一定就满足equals。不过为了提高效率,应该尽量使上面两个条件接近等价。
wait方法:wait方法就是使当前线程等待该对象的锁,当前线程必须是该对象的拥有者,也就是具有该对象的锁。wait()方法一直等待,直到获得锁或者被中断。wait(long timeout)设定一个超时间隔,如果在规定时间内没有获得锁就返回。
调用该方法后当前线程进入睡眠状态,直到以下事件发生。
(1)其他线程调用了该对象的notify方法。
(2)其他线程调用了该对象的notifyAll方法。
(3)其他线程调用了interrupt中断该线程。
(4)时间间隔到了。
此时该线程就可以被调度了,如果是被中断的话就抛出一个InterruptedException异常。
notify方法:该方法唤醒在该对象上等待的某个线程。
notifyAll方法:该方法唤醒在该对象上等待的所有线程。
hashCode()
方法是 Java 中 Object
类的一个方法,用于返回对象的哈希码(散列码)。它的作用是为了提高哈希表(如 HashMap
、HashSet
等)的性能。哈希码是一种用于快速定位对象存储位置的技术。在使用哈希表时,对象被存储在哈希桶(数组)中,哈希表会根据对象的哈希码来确定对象存储的位置,以便快速查找、插入或删除对象。
hashCode()
方法的具体作用包括:
作为哈希表的索引: 哈希表使用对象的哈希码来确定对象存储在数组中的位置。每个对象都有一个哈希码,哈希表会根据对象的哈希码来计算出存储位置,以便快速定位对象。
作为对象在集合中的唯一标识: 在使用集合类(如 HashSet
、HashMap
等)时,对象的哈希码用于检查对象是否已经存在于集合中。如果两个对象的哈希码相同,集合类会进一步调用对象的 equals()
方法来比较对象的内容是否相同。
作为对象在分布式系统中的唯一标识: 在分布式系统中,对象的哈希码可以用于分片(Sharding)和路由等操作,以便将对象均匀分布在不同的节点上,提高系统的扩展性和性能。
因此,实现良好的 hashCode()
方法可以提高哈希表的性能和效率,并且能够在集合中正确地处理对象的唯一性和相等性。
在 Java 中,一个字符占用 2 个字节,即 16 位。这是因为 Java 使用 Unicode 字符集来表示字符,其中每个字符都用 16 位表示,因此一个字符占用 2 个字节。
至于其他基本数据类型的大小,可以根据 Java 虚拟机规范来确定:
需要注意的是,Java 虚拟机规范中并没有强制规定各种基本数据类型的大小,而是要求实现者根据规范的要求来实现。因此,以上大小是 Java 中常见的实现方式,但并非所有的 Java 虚拟机都必须按此方式实现。
未精确定义字节。
首先在Java中定义的八种基本数据类型中,除了其它七种类型都有明确的内存占用字节数外,就boolean类型没有给出具体的占用字节数,因为对虚拟机来说根本就不存在 boolean 这个类型,boolean类型在编译后会使用其他数据类型来表示。
boolean类型没有给出精确的定义,《Java虚拟机规范》给出了4个字节,和boolean数组1个字节的定义,具体还要看虚拟机实现是否按照规范来,所以1个字节、4个字节都是有可能的。这其实是运算效率和存储空间之间的博弈,两者都非常的重要。
在 Java 中,==
运算符和 equals()
方法是用于比较对象之间的差异的两种不同方式。
==
运算符:
==
运算符用于比较两个对象的引用是否指向同一个内存地址,即判断两个对象是否是同一个对象的引用。==
运算符用于比较基本数据类型时,它会比较它们的值。==
比较的是对象的引用地址,如果两个对象的引用地址相同,则返回 true
,表示这两个对象是同一个对象;如果引用地址不同,则返回 false
。equals()
方法:
equals()
方法是 Object 类的一个方法,用于比较两个对象的内容是否相等,即判断两个对象是否逻辑上相等。equals()
方法的行为与 ==
运算符相同,即比较对象的引用地址。equals()
方法,以便比较对象的内容而不是引用地址。==
运算符用于比较两个对象的引用地址是否相同,而 equals()
方法用于比较两个对象的内容是否相同。在实际应用中,如果需要比较对象的内容,通常应该使用 equals()
方法,而不是 ==
运算符。
String str=“hello world”
通过直接赋值的形式可能创建一个或者不创建对象,如果"hello world"在字符串池中不存在,会在java字符串池中创建一个String对象(“hello world”),常量池中的值不能有重复的,所以当你通过这种方式创建对象的时候,java虚拟机会自动的在常量池中搜索有没有这个值,如果有的话就直接利用他的值,如果没有,他会自动创建一个对象,所以,str指向这个内存地址,无论以后用这种方式创建多少个值为”hello world”的字符串对象,始终只有一个内存地址被分配。
String str=new String(“hello world”)
通过new 关键字至少会创建一个对象,也有可能创建两个。
因为用到new关键字,肯定会在堆中创建一个String对象,如果字符池中已经存在"hello world",则不会在字符串池中创建一个String对象,如果不存在,则会在字符串常量池中也创建一个对象。他是放到堆内存中的,这里面可以有重复的,所以每一次创建都会new一个新的对象,所以他们的地址不同。
String 有一个intern() 方法,native,用来检测在String pool是否已经有这个String存在。
基本对比:
使用时的建议:
如何鉴定线程安全:
查看源代码便一目了然,事实上,StringBuilder和StringBuffer类拥有的成员属性以及成员方法基本相同,区别是StringBuffer类的成员方法前面多了一个关键字:synchronized,不用多说,这个关键字是在多线程访问时起到安全保护作用的,也就是说StringBuffer是线程安全的。
StringBuffer 和 StringBuilder 都是可变的字符串类,它们底层的实现方式略有不同:
StringBuffer
StringBuilder
无论是 StringBuffer 还是 StringBuilder,它们的底层实现都是通过字符数组进行字符串的存储和操作。通过动态调整字符数组的大小,它们可以高效地进行字符串的修改和拼接操作。使用字符数组存储字符串内容的好处是可以避免频繁创建新的字符串对象,从而提高性能和内存利用率。
在 Java 中,switch
语句支持的数据类型有限,包括整型数据和枚举类型。具体来说,switch
语句支持的数据类型有:
byte
、short
、int
和 char
。switch
语句也支持枚举类型。对于 long
类型数据,switch
语句是不支持的。如果需要在 switch
语句中使用 long
类型数据,可以考虑将其转换为 int
或 byte
类型,或者使用一系列的 if-else
语句来实现相同的逻辑。
在 Java 中,创建一个类的实例(对象)有以下几种常见的方式:
使用 new
关键字: 最常见的创建对象的方式是使用 new
关键字,通过调用类的构造方法来创建对象。例如:
MyClass obj = new MyClass();
使用反射机制: Java 提供了反射机制,可以在运行时动态地创建对象。通过 Class
类的 newInstance()
方法来创建对象。例如:
- Class<?> clazz = MyClass.class;
- MyClass obj = (MyClass) clazz.newInstance();
通过对象克隆: 如果一个类实现了 Cloneable
接口,并且重写了 clone()
方法,那么可以通过对象的克隆来创建新的对象。例如:
- MyClass obj1 = new MyClass();
- MyClass obj2 = (MyClass) obj1.clone();
通过反序列化: 可以将对象序列化为字节流,然后再反序列化为对象。通过 ObjectInputStream
类的 readObject()
方法来创建对象。例如:
- FileInputStream fileIn = new FileInputStream("object.ser");
- ObjectInputStream in = new ObjectInputStream(fileIn);
- MyClass obj = (MyClass) in.readObject();
使用工厂方法或者设计模式: 可以使用工厂方法模式、建造者模式等设计模式来创建对象,以封装对象的创建过程。例如:
MyClass obj = MyClassFactory.createInstance();
使用匿名类: 可以通过定义匿名类来创建对象,尤其在创建接口实例时较为常见。例如:
- Runnable runnable = new Runnable() {
- public void run() {
- // 实现 run 方法
- }
- };
以上是 Java 中创建对象的常见方式,可以根据具体的需求和场景选择合适的方式。
final,finally,finalize之间长得像但一点关系都没有,仅仅是长的像!
final 表示不可修改的,可以用来修饰类,方法,变量。
finally是Java的异常处理机制中的一部分。finally块的作用就是为了保证无论出现什么情况,finally块里的代码一定会被执行。
finalize是Object类的一个方法,是GC进行垃圾回收前要调用的一个方法。
Java 8新增特性:
Java 7新增特性:
除了Java 8和Java 7,其他版本的Java也都引入了一些新特性和改进,其中一些主要的特性包括:
Java 9:
Java 10:
Java 11:
Java 12:
Java 13:
Java 14:
Java 15:
Java 16:
Java 17:
Java 18(计划中):
这些是Java 9到18版本的一些重要新增特性和改进。请注意,每个版本可能还包含了其他小的改进、修复和性能优化。建议参考官方文档和相关资源以获取更详细和全面的信息。
Lambda 表达式是 Java 8 引入的一个重要特性,它提供了一种更简洁、更灵活的方式来编写匿名函数。Lambda 表达式的引入主要解决了以下两个问题,并在某些情况下优化了代码。
匿名内部类的冗余代码: 在 Java 7 及之前的版本中,要实现一个简单的功能,常常需要编写大量的匿名内部类。这些类会增加代码量并使代码显得冗余。Lambda 表达式通过简化匿名内部类的写法,让开发者能够更紧凑地表达逻辑,减少冗余代码。
代码可读性和可维护性: Lambda 表达式使代码更具可读性。通过将逻辑放在更接近使用它的地方,可以更清晰地传达代码的意图。这使得代码更易于理解和维护。
相比 Java 7 的方式,Lambda 表达式的引入在以下几个方面进行了优化:
简洁性: 使用 Lambda 表达式可以大大减少冗余的语法,让代码更加紧凑。特别是在处理集合、流式处理以及函数式编程方面,代码的可读性和简洁性得到了明显的提升。
迭代集合的优化: 在 Java 7 中,迭代集合需要通过 foreach 循环或迭代器来完成,而 Lambda 表达式和 Stream API 让集合的处理变得更加优雅,同时还能够自动利用多核处理器进行并行处理。
函数式编程: Lambda 表达式为 Java 引入了函数式编程的元素,使得在 Java 中更容易表达和使用函数式概念,如高阶函数、闭包等。
总之,Lambda 表达式的引入使 Java 编程更具现代化和函数式特性,使代码更具可读性、简洁性,同时提供了更好的性能优化和并行处理能力。这对于简化开发和编写高效代码都具有积极影响。
在实际运行中,基于 Lambda/Stream 的版本(lambdaMaxInteger),比传统的 for-each 版本(forEachLoopMaxInteger)慢很多。
- // 一个大的ArrayList,内部是随机的整形数据
- volatile List<Integer> integers = …
-
- // 基准测试1
- public int forEachLoopMaxInteger() {
- int max = Integer.MIN_VALUE;
- for (Integer n : integers) {
- max = Integer.max(max, n);
- }
- return max;
- }
-
- // 基准测试2
- public int lambdaMaxInteger() {
- return integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
- }
第一,基准测试是一个非常有效的通用手段,让我们以直观、量化的方式,判断程序在特定条件下的性能表现。
第二,基准测试必须明确定义自身的范围和目标,否则很有可能产生误导的结果。前面代码片段本身的逻辑就有瑕疵,更多的开销是源于自动装箱、拆箱(auto-boxing/unboxing),而不是源自 Lambda 和 Stream,所以得出的初始结论是没有说服力的。
第三,虽然 Lambda/Stream 为 Java 提供了强大的函数式编程能力,但是也需要正视其局限性:
API(Application Programming Interface)是一组定义了程序之间如何交互的规则和协议,提供了访问和使用某个软件组件、库或服务的接口。API 描述了如何调用和使用已经存在的功能。开发者可以通过调用 API 中的函数、方法等来使用底层的功能,而不需要关心具体的实现细节。
SPI(Service Provider Interface)则是一种设计模式,它用于在软件中提供可扩展的功能实现。SPI 允许开发者在不修改核心代码的情况下,通过插件或扩展点来增加或替换功能的实现。在 SPI 中,核心代码定义了一组接口或抽象类,而实际的实现则由不同的服务提供者来提供。这种设计方式使得系统的扩展性更好,可以更容易地添加新的功能实现。
API(Application Programming Interface):
SPI(Service Provider Interface):
在 Java 中,SPI 的底层实现通常是通过在 META-INF/services/
目录下创建配置文件,其中列出了实现了某个接口的类的全限定名。这些配置文件被加载器读取,以实现在运行时发现并加载不同的服务提供者。Java 标准库中的许多功能(如日志、数据库驱动、XML 解析器等)都使用了 SPI 设计模式来实现可扩展性。
深克隆(Deep Clone)和浅克隆(Shallow Clone)是针对对象克隆(Clone)操作的两种方式:
浅克隆(Shallow Clone):
深克隆(Deep Clone):
浅克隆通常比深克隆操作快速和简单,但是在某些情况下可能会导致意外的行为,因为克隆对象和原始对象之间共享引用对象。在需要完全独立的对象副本时,深克隆是更可靠的选择。
伪共享(False Sharing)是一种硬件和软件交互的现象,它可能对多线程程序的性能产生负面影响。
下面是对伪共享机制的简要分析:
伪共享通常发生在多个线程同时访问不同但位于同一缓存行(Cache Line)的数据时。缓存行是计算机内存中缓存的最小单位,通常是64字节。当多个线程同时修改或读取不同的数据,但这些数据位于同一缓存行时,就会引发伪共享问题。
当一个线程修改缓存行中的某个数据时,该缓存行会被标记为”脏”,并且会将整个缓存行的数据刷新到主内存中。这将导致其他线程对于同一缓存行中的数据的缓存失效,即其他线程需要从主内存重新加载该缓存行的数据。这种缓存失效的频繁发生会导致性能下降。
伪共享问题的解决方案之一是通过对共享的数据进行填充(Padding),使得不同线程访问的数据分散到不同的缓存行上,从而避免了不必要的缓存行失效。填充可以通过在数据结构中添加额外的空间或使用特定的对齐方式来实现。
另一种解决伪共享问题的方法是使用缓存行对齐(Cache Line Alignment)技术。这种技术通过将数据结构的每个成员对齐到缓存行的边界,确保不同线程访问的数据位于不同的缓存行中,减少了缓存行的失效次数。
总而言之,伪共享是由于多个线程同时访问同一缓存行中不同数据而导致的性能问题。通过填充和缓存行对齐等技术,可以减少伪共享对多线程程序性能的影响,提高系统的并发性能。
当在类加载机制中遇到同名类的情况时,Java 虚拟机(JVM)会根据双亲委派模型来进行处理。这个模型要求除了顶层的启动类加载器(Bootstrap ClassLoader)外,每个类加载器在加载类时,首先委托其父类加载器进行加载,只有在父类加载器无法加载该类时,才由当前类加载器自行加载。
具体来说,如果在加载某个类时遇到同名类,JVM 会按照以下步骤进行处理:
委派给父类加载器: 当前类加载器会首先委派给父类加载器进行加载。父类加载器会按照双亲委派模型,先尝试从自己的缓存中查找已加载的类,如果找到了则直接返回;如果没有找到,则继续委派给其父类加载器加载。
依次向上委派: 类加载请求会依次向上委派,直到达到顶层的启动类加载器。如果所有父类加载器都无法加载该类,则当前类加载器会尝试自己加载该类。
本地加载: 当前类加载器在自己的类路径下查找并加载该类。如果找到了同名类,则直接加载;如果没有找到,则抛出类未找到异常(ClassNotFoundException)。
综上所述,如果第三方的 jar 包中包含了与自己代码中相同名称的类,首先会由系统类加载器或者扩展类加载器进行加载,只有当这两个类加载器都无法找到该类时,才会由自定义的类加载器加载。这样可以确保在运行时不会混淆相同名称的类。
Java IO 方式有很多种,基于不同的 IO 抽象模型和交互方式,可以进行简单区分。
第一,传统的 java.io 包,它基于流模型实现,提供了我们最熟知的一些 IO 功能,比如 File 抽象、输入输出流等。交互方式是同步、阻塞的方式,也就是说,在读取输入流或者写入输出流时,在读、写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
java.io 包的好处是代码比较简单、直观,缺点则是 IO 效率和扩展性存在局限性,容易成为应用性能的瓶颈。
很多时候,人们也把 java.net 下面提供的部分网络 API,比如 Socket、ServerSocket、HttpURLConnection 也归类到同步阻塞 IO 类库,因为网络通信同样是 IO 行为。
第二,在 Java 1.4 中引入了 NIO 框架(java.nio 包),提供了 Channel、Selector、Buffer 等新的抽象,可以构建多路复用的、同步非阻塞 IO 程序,同时提供了更接近操作系统底层的高性能数据操作方式。
第三,在 Java 7 中,NIO 有了进一步的改进,也就是 NIO 2,引入了异步非阻塞 IO 方式,也有很多人叫它 AIO(Asynchronous IO)。异步 IO 操作基于事件和回调机制,可以简单理解为,应用操作直接返回,而不会阻塞在那里,当后台处理完成,操作系统会通知相应线程进行后续工作。
接口和抽象类是 Java 面向对象设计的两个基础机制。
接口是对行为的抽象,它是抽象方法的集合,利用接口可以达到 API 定义和实现分离的目的。接口,不能实例化;不能包含任何非常量成员,任何 field 都是隐含着 public static final 的意义;同时,没有非静态方法实现,也就是说要么是抽象方法,要么是静态方法。Java 标准类库中,定义了非常多的接口,比如 java.util.List。
抽象类是不能实例化的类,用 abstract 关键字修饰 class,其目的主要是代码重用。除了不能实例化,形式上和一般的 Java 类并没有太大区别,可以有一个或者多个抽象方法,也可以没有抽象方法。抽象类大多用于抽取相关 Java 类的共用方法实现或者是共同成员变量,然后通过继承的方式达到代码复用的目的。Java 标准库中,比如 collection 框架,很多通用部分就被抽取成为抽象类,例如 java.util.AbstractList。
Java 类实现 interface 使用 implements 关键词,继承 abstract class 则是使用 extends 关键词,我们可以参考 Java 标准库中的 ArrayList。
- public class ArrayList<E> extends AbstractList<E>
- implements List<E>, RandomAccess, Cloneable, java.io.Serializable
- {
- //...
- }
针对人员:
1.全部人员都适用;
2.正式员工:针对应届+工作1-3年的面试者,加大比重;3年以上的这部分可适量;
3.外包员工:本部分比重可减轻,但针对1-3年的需要适当增加比重;
以下回答进行简单简述如下。
当调用HashMap的put(key, value)方法时,会执行以下步骤:
当调用HashMap的get(key)方法时,会执行以下步骤:
需要注意的是,HashMap使用哈希码和相等性比较来确定键值对的存储位置和查找操作。因此,在自定义对象作为键时,确保正确实现equals()和hashCode()方法非常重要,以保证HashMap的正确性和一致性。
我们重点还是分析put的内容,下图展开方便更深的理解:
哈希表(Hash Table)在存储元素时使用哈希函数将元素的键映射到一个固定的数组位置上,这个数组被称为桶(bucket)。扩容是指在哈希表中的桶数量不足以容纳当前元素数量时,自动增加桶的数量。
哈希表扩容的主要目的是保持哈希表的负载因子(Load Factor)在一个合适的范围内。负载因子是指当前哈希表中存储元素的数量与桶的数量之比。
为什么需要控制负载因子呢?因为负载因子过高会导致哈希冲突的概率增加,即多个元素映射到同一个桶的可能性增大,进而降低哈希表的性能。通过扩容,可以增加桶的数量,从而降低负载因子,减少哈希冲突的发生,提高哈希表的效率和性能。
扩容的具体过程如下:
需要注意的是,哈希表的扩容是一项开销较大的操作,因为需要重新计算哈希值、重新分配桶,并且需要移动元素。为了减少频繁的扩容操作,通常在设计哈希表时会预估元素的数量,并根据预估值初始化合适大小的初始桶数组。此外,选择适当的负载因子阈值也是重要的,以平衡空间利用率和性能。
总结起来,哈希表扩容是为了保持合适的负载因子,减少哈希冲突,提高哈希表的性能和效率。
HashMap
在多线程环境下可能会出现线程不安全的问题,主要原因包括以下几点:
非同步操作: HashMap
是非同步的,即它不对多线程进行同步控制。在多线程并发操作的情况下,可能会导致竞争条件,从而引发不确定的行为。
并发扩容问题: 在 HashMap
进行扩容的时候,即在原有的数组上重新分配空间并重新计算哈希值,可能会导致多个线程同时修改 HashMap
结构,从而破坏内部数据结构,引发异常或导致死循环。
链表成环问题: 在 JDK7 中,在多线程环境下,由于链表的头插法和并发扩容的原因,可能导致链表成环。当一个线程正在进行链表的迁移操作,另一个线程插入新节点时,可能造成链表成环,从而导致死循环。
并发操作引发的数据丢失问题: 当多个线程同时进行 put
操作时,可能会导致部分数据的丢失。例如,两个线程同时判断需要进行扩容,都计算了新的数组位置,然后分别在新位置进行插入操作,这样其中一个线程的插入操作会被覆盖,导致数据丢失。
为了解决以上问题,可以采取以下措施:
使用线程安全的集合类: 使用 Collections.synchronizedMap
或者 ConcurrentHashMap
来替代普通的 HashMap
,这两者都提供了一定程度的线程安全性。
手动同步控制: 在对 HashMap
进行操作时,使用显式的同步控制,例如使用 synchronized
关键字,确保在多线程环境下对 HashMap
的修改是同步的。
使用并发安全的Map实现: ConcurrentHashMap
是 Java 提供的并发安全的 Map 实现,它在设计上避免了一些 HashMap
中的问题,提供更好的并发性能。
数据结构上
Hash值的计算上
链表数据的插入方法上
内部Entry类的实现上
- static class Entry<K,V> implements Map.Entry<K,V> {
- final K key;
- V value;
- Entry<K,V> next;
- int hash;
- }
- static class Node<K,V> implements Map.Entry<K,V> {
- final int hash;
- final K key;
- V value;
- Node<K,V> next;
- }
HashSet和HashMap是Java集合框架中的两个常用类,它们具有一些共同的特点,但也有一些区别。以下是对HashSet和HashMap的对比分析和一些建议:
共同点:
区别:
使用建议:
如果只需要存储元素且不关心键值对: 使用HashSet
,它提供了唯一性和集合操作。
如果需要存储键值对: 使用HashMap
,它提供了键值对的存储和检索功能。
如果只关心键的唯一性: 如果只关心键的唯一性而不需要值,也可以使用HashMap
并将值设为常量。
性能注意事项: 在需要频繁查找、插入、删除元素的场景下,HashMap
通常更适用。但如果只关心唯一性、集合操作且不需要键值对关系时,HashSet
可能更简洁。
HashMap
的早期版本(JDK7 及之前),使用的确实是单向链表而非双向链表。这是因为在实际的使用场景中,插入和删除节点时,只需要修改节点前后的指针即可,而不需要访问到当前节点的前一个节点。使用单链表可以降低节点的存储开销,因为不需要额外的指针指向前一个节点。以java7为背景情况下回答如下:
HashTable
HashMap
ConcurrentHashMap(简单简述java7的,java8见上面的讲解)
注意,在 JDK8 中,HashMap
进行了一些优化,如引入红黑树、采用尾插法等。但在高并发环境下,ConcurrentHashMap
在设计上更注重并发性能, 能够更好地保持性能,并且它提供了更多的灵活性,允许部分并发读写操作。如果不需要并发操作,而且对性能要求不高,可以选择 HashMap
;如果需要线程安全,可以考虑 ConcurrentHashMap
或 HashTable
。
HashMap和ConcurrentHashMap是Java中的两种不同类型的映射(Map)实现。它们有以下几个区别:
综上所述,如果需要在多线程环境下使用映射数据结构并且需要高并发性能,则应该使用ConcurrentHashMap。而在单线程环境下或者不需要并发安全的情况下,HashMap是更简单、更高效的选择。
当选择使用HashMap或ConcurrentHashMap时,以下是一些建议:
使用HashMap:
使用ConcurrentHashMap:
总结:
在JDK1.7版本中,ConcurrentHashMap维护了一个Segment数组,Segment这个类继承了重入锁ReentrantLock,并且该类里面维护了一个 HashEntry<K,V>[] table数组,在写操作put,remove,扩容的时候,会对Segment加锁,所以仅仅影响这个Segment,不同的Segment还是可以并发的,所以解决了线程的安全问题,同时又采用了分段锁也提升了并发的效率。
在JDK1.8版本中,ConcurrentHashMap摒弃了Segment的概念,而是直接用Node数组+链表+红黑树的数据结构来实现,并发控制使用Synchronized和CAS来操作,整个看起来就像是优化过且线程安全的HashMap。
在JDK1.8版本中,对于size的计算,在扩容和addCount()时已经在处理了。JDK1.7是在调用时才去计算。
重点该问题产生于jdk7中(jdk8已经解决了“环形链表”,其采用了尾插法而非反转链表的方式),HashMap成环原因的代码出现在transfer代码中,也就是扩容之后的数据迁移部分,代码如下:
- void transfer(Entry[] newTable, boolean rehash) {
- int newCapacity = newTable.length;
- for (Entry<K,V> e : table) {
- while(null != e) {
- Entry<K,V> next = e.next;
- if (rehash) {
- e.hash = null == e.key ? 0 : hash(e.key);
- }
- int i = indexFor(e.hash, newCapacity);
- e.next = newTable[i];
- newTable[i] = e;
- e = next;
- }
- }
- }
解释一下transfer的过程:首先获取新表的长度,之后遍历新表的每一个entry,然后每个ertry中的链表以反转的形式形成rehash之后的链表。
并发问题:若当前线程一此时获得entry节点,但是被线程中断无法继续执行,此时线程二进入transfer函数,并把函数顺利执行,此时新表中的某个位置有了节点,之后线程一获得执行权继续执行,因为并发transfer,所以两者都是扩容的同一个链表,当线程一执行到e.next = new table[i] 的时候,由于线程二之前数据迁移的原因导致此时new table[i] 上就有ertry存在,所以线程一执行的时候,会将next节点,设置为自己,导致自己互相使用next引用对方,因此产生链表,导致死循环。
解决问题:
线程安全的集合类通常在 java.util.concurrent
包下,以下是几种常见的线程安全的集合类:
ConcurrentHashMap
: 用于代替 Hashtable
,它提供了线程安全的键值对存储,并且性能比 Hashtable
更好。
CopyOnWriteArrayList
和 CopyOnWriteArraySet
: 这两个类提供了线程安全的动态数组和集合,它们在遍历操作频繁而修改操作较少的情况下性能很好。
ConcurrentLinkedQueue
和 ConcurrentLinkedDeque
: 这两个类提供了线程安全的队列和双端队列的实现。
BlockingQueue
接口的实现类: ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
等,它们提供了阻塞队列的实现,可以在多线程环境中安全地进行数据交换。
ConcurrentSkipListMap
和 ConcurrentSkipListSet
: 这两个类提供了线程安全的有序映射和有序集合的实现,基于跳表的数据结构实现。
这些线程安全的集合类可以在多线程环境中安全地使用,不需要额外的同步措施。然而,需要注意的是,虽然这些集合类提供了线程安全的操作,但并不意味着它们可以完全替代同步措施,有时仍然需要在多线程访问时进行额外的同步操作。
Collections.synchronizedCollection(Collection<T> c)
方法的实现原理主要涉及以下几个方面:
包装原始集合: 方法会创建一个 SynchronizedCollection
对象作为包装器,其中内部持有传入的原始集合 c
。
同步化操作: 在 SynchronizedCollection
类中,对于所有会修改集合状态的操作(例如添加、删除元素),都会使用 synchronized
关键字修饰,以确保在多线程环境下的线程安全性。通过对 mutex
对象进行同步化,实现了对原始集合的同步化操作。
锁对象: SynchronizedCollection
类中定义了一个 mutex
对象作为同步锁。这个锁对象通常是 this
或者 SynchronizedCollection
对象本身。使用 synchronized
块来锁定这个锁对象,以确保对原始集合的操作是互斥的,从而保证了线程安全性。
透明性: 返回的 SynchronizedCollection
对象对外部用户来说是透明的,用户不需要知道内部实现的细节,只需要知道它是一个线程安全的集合。这种透明性使得用户可以像操作普通集合一样操作线程安全集合,提高了使用的便捷性。
Collections.synchronizedCollection(Collection<T> c)
方法通过创建一个包装器类,使用同步化的方式对原始集合的操作进行处理,从而实现了对传入集合的线程安全封装。
Array和ArrayList是Java中用于存储和操作多个元素的数据结构,它们有一些区别和使用时需要注意的事项。
固定大小 vs 动态大小:
类型限制:
增删元素:
遍历:
性能:
注意事项:
根据具体的需求和场景,选择适合的数据结构。如果需要灵活的大小调整和内置操作方法,可以使用ArrayList;如果需要更高的性能和直接的内存访问,可以使用Array。
List 接口的实现类:
Set 接口的实现类:
Map 接口的实现类:
对于 List 接口的实现类,可以使用 Collections 类的静态方法 sort()
来排序。如果需要自定义排序规则,可以传入一个 Comparator 对象给 sort()
方法。List 排序的底层原理取决于具体使用的排序算法。在 Java 中,Collections.sort()
方法使用了归并排序(Merge Sort)或者快速排序(Quick Sort)算法来对 List 进行排序。
归并排序(Merge Sort):
快速排序(Quick Sort):
在具体实现中,Collections.sort()
方法的具体实现会根据 List 的大小和元素类型选择合适的排序算法。通常情况下,对于小规模的 List,会使用插入排序(Insertion Sort)或者二分插入排序(Binary Insertion Sort)来进行排序,因为这些排序算法在小规模数据上有较好的性能表现。而对于大规模的 List,则会使用归并排序(Merge Sort)或者快速排序(Quick Sort)来进行排序,因为这些排序算法在大规模数据上有较好的性能表现。
ArrayList 和 LinkedList 是 Java 中常见的两种 List 实现,它们的内部实现有所不同,适用于不同的场景。
ArrayList 内部实现:
LinkedList 内部实现:
区别和适应场景:
总的来说,ArrayList 的优势在于快速随机访问,而 LinkedList 的优势在于快速插入和删除。根据实际需求选择合适的实现,以获得更好的性能和效率。
针对人员:
1.全部人员都适用;
2.正式员工:针对应届+工作1-3年的面试者,减少比重;3年以上的这部分可加大比重;
3.外包员工:本部分比重可减轻,但针对1-3年的需要适当增加比重;3年以上的必考;
进程和线程是操作系统中的两个重要概念,它们有以下区别:
总结来说,进程和线程是操作系统中的两个基本概念,进程是资源分配的单位,而线程是执行的单位。进程之间相互独立,线程在同一进程内共享资源。线程切换开销小,可以实现更高效的并发执行。在设计和开发应用程序时,需要根据具体需求和系统架构选择适合的进程和线程模型。
进程间通信(Inter-Process Communication,IPC)和线程间通信(Inter-Thread Communication,ITC)是实现进程或线程之间数据交换和信息共享的方式。在操作系统中,进程间通信和线程间通信通常采用以下方式:
进程间通信(IPC):
线程间通信(ITC):
通过这些通信方式,进程间或线程间可以进行数据交换、同步操作等。选择合适的通信方式取决于具体的需求和场景。
在Java中,线程之间可以通过以下几种方式进行通信:
这些是常见的线程间通信方式,具体的选择取决于场景和需求。需要根据具体情况选择合适的通信方式,并使用正确的同步机制来保证线程间的安全性和可靠性。
并发(Concurrency)和并行(Parallelism)是计算机领域中两个相关但不同的概念:
并发(Concurrency)
并发指的是在同一时间段内执行多个任务或处理多个事件。它强调多个任务之间的交替执行和共享资源的竞争。在并发情况下,多个任务通过快速的切换,使得它们似乎是同时执行的。并发可以提高系统的吞吐量和资源利用率,并改善响应时间。
并行(Parallelism)
并行指的是同时执行多个任务或处理多个事件。在并行情况下,多个任务真正地同时执行,每个任务占用不同的物理处理器核心或计算资源。并行利用了多核处理器或分布式系统的优势,通过同时处理多个任务来提高系统的处理能力和性能。
总结来说:
可以将并发视为一种逻辑上的概念,强调任务之间的关系和调度方式,而并行则是一种物理上的概念,强调任务的同时执行。
在实际应用中,通过并发和并行的技术可以提高系统的性能和响应能力。例如,通过多线程实现并发处理、利用多核处理器实现并行计算、使用分布式系统实现并行处理等。
Java 线程的状态可以分为以下几种:
新建(New): 线程对象被创建但还未启动时的状态。此时线程对象已经被创建,但是还没有调用 start()
方法启动线程。
就绪(Runnable): 线程对象调用了 start()
方法后,线程处于就绪状态。此时线程已经准备好运行,但是还未获得 CPU 执行时间。
运行(Running): 线程获取到 CPU 执行时间,开始执行任务的状态。处于运行状态的线程正在执行任务代码。
阻塞(Blocked): 线程因为某些原因暂时无法执行任务而被阻塞的状态。常见的情况包括等待某个资源(如锁)、等待输入输出操作完成、等待其他线程执行完毕等。
等待(Waiting): 线程调用了 wait()
方法后进入等待状态。在等待状态下,线程会等待其他线程调用 notify()
或 notifyAll()
方法来唤醒它。
超时等待(Timed Waiting): 线程调用了 sleep()
、join()
或 LockSupport.parkNanos()
等方法,并设置了等待时间,线程会进入超时等待状态。在超时等待状态下,线程会等待指定的时间,如果时间到了仍未被唤醒,线程会自动唤醒并进入就绪状态。
终止(Terminated): 线程执行完任务或者因异常而终止时的状态。处于终止状态的线程不会再执行任务,线程对象也会被销毁。
这些状态在 Java 线程的生命周期中是动态变化的,线程会根据不同的情况在各个状态之间转换。
BLOCKED
(阻塞)和 WAITING
(等待)是 Java 线程状态中的两种不同状态,它们之间有以下区别:
BLOCKED(阻塞):
BLOCKED
状态通常是因为等待某个锁的释放而被阻塞。BLOCKED
状态。BLOCKED
状态下,会等待其他线程释放锁资源,以便获取锁并继续执行任务。WAITING(等待):
WAITING
状态通常是因为需要等待特定的条件才能继续执行,而不是等待锁的释放。Object.wait()
、Thread.join()
、LockSupport.park()
等方法时,会进入 WAITING
状态。WAITING
状态下,会一直等待特定条件的出现或者其他线程的唤醒。wait()
方法等待其他线程的通知,或者调用 join()
方法等待指定线程执行完毕。总的来说,BLOCKED
状态是因为等待锁资源而被阻塞,而 WAITING
状态是因为等待特定条件或其他线程的唤醒而被阻塞。两者的区别在于等待的对象不同,导致了不同的线程状态。
在 Java 中,有多种方式可以实现多线程。以下是一些常见的方法:
1.继承 Thread 类
Thread
类。run
方法,在 run
方法中定义线程执行的任务。start
方法启动线程。2.实现 Runnable 接口:
Runnable
接口。run
方法,定义线程执行的任务。Thread
类的构造方法。start
方法启动线程。3.使用匿名类:
Thread
类或实现 Runnable
接口。run
方法,定义线程执行的任务。start
方法。4.使用 Callable 和 Future:
Callable
接口的类。call
方法,定义线程执行的任务,并返回结果。ExecutorService
提交 Callable
对象,得到 Future
对象,通过 Future
可以获取线程的执行结果。5.使用 Executor 框架:
Executor
框架提供的线程池。Runnable
接口或 Callable
接口的类。Executor
。这些是 Java 中常用的多线程实现方式。选择合适的方式取决于任务的性质和对线程的管理需求。
Java处理多线程的方式有以下几种:
这些方式可以帮助处理多线程编程中的并发和同步问题,提高程序的性能和可靠性。具体使用哪种方式取决于具体的需求和场景。
在 Java 中,启动一个线程应该使用 start()
方法,而不是直接调用 run()
方法。
start()
方法启动一个线程会创建一个新的线程,并在新线程中执行 run()
方法的内容。这样做会实现多线程并发执行的效果。run()
方法只会在当前线程中执行 run()
方法的内容,并不会创建新的线程。这样做并不会实现多线程的效果,只是简单地执行了一个方法而已。因此,如果希望实现多线程并发执行的效果,应该调用 start()
方法来启动线程。
Thread 的 start 方法调用两次会怎么样?
如果 Thread
的 start()
方法被调用两次,第二次调用会导致 IllegalThreadStateException
异常被抛出。这是因为 Thread
类内部维护了一个状态机,用来标识线程的状态。在调用 start()
方法时,会检查线程状态是否处于新建状态(即线程还未启动)。如果线程处于新建状态,则可以启动线程执行并将状态转换为就绪状态;如果线程不处于新建状态(例如已经处于就绪状态、运行状态等),再次调用 start()
方法就会抛出 IllegalThreadStateException
异常,因为线程已经启动或正在执行,无法重新启动。
Thread 的 start 方法调用只有一次生效的原因?
start()
方法是 Thread
类的一个同步方法,内部使用了synchronized
来确保线程状态转换的原子性。这样可以防止多个线程同时调用 start()
方法导致的竞态条件问题。当一个线程调用 start()
方法时,会获取 Thread
对象的锁,执行线程状态转换的过程,如果另一个线程尝试再次调用 start()
方法,由于该方法被 synchronized
修饰,需要等待前一个线程释放锁才能执行,因此确保了 start()
方法只能被调用一次生效的特性。
在 Java 中,守护线程(Daemon Thread)是一种特殊类型的线程,其特点是当所有非守护线程结束时,守护线程会自动结束,从而随着 JVM 的退出而结束。守护线程与普通线程的区别在于它们的生命周期不会影响 JVM 的终止。守护线程的特点包括:
在后台运行: 守护线程通常用于执行一些后台任务,不需要用户主动控制的工作。它们在后台默默地运行,不会干扰到用户主线程的执行。
随着 JVM 的终止而结束: 当所有非守护线程结束时,JVM 会自动关闭,守护线程也会随之结束。这样可以避免守护线程继续运行导致 JVM 无法正常退出的问题。
不影响 JVM 的终止: 守护线程的生命周期不会影响 JVM 的终止。当所有非守护线程结束时,JVM 会检查是否还有守护线程在运行,如果没有,则会正常退出;如果有,则会强制结束所有守护线程并退出。
守护线程通常用于执行一些后台任务,例如垃圾回收器(Garbage Collector)就是一个典型的守护线程。垃圾回收器在后台不断地回收无用的内存资源,以便释放内存空间,但它并不需要用户主动控制,而是由 JVM 自动管理。
总的来说,守护线程的作用是在后台执行一些不需要用户主动控制的任务,它们的生命周期不会影响 JVM 的终止,可以提高系统的稳定性和可靠性。
要实现两个线程的串行执行,可以使用线程间的协调机制来控制它们的执行顺序。以下是几种常见的方法:
使用 join() 方法: 在一个线程中调用另一个线程的 join()
方法,会等待该线程执行完成后再继续执行当前线程。通过这种方式,可以实现两个线程的串行执行。例如:
- Thread thread1 = new Thread(new MyTask1());
- Thread thread2 = new Thread(new MyTask2());
- thread1.start();
- thread1.join(); // 等待 thread1 执行完成
- thread2.start(); // thread2 在 thread1 执行完成后再启动
使用 wait() 和 notify() 方法: 可以在一个线程中使用 wait()
方法使其进入等待状态,然后在另一个线程中使用 notify()
或 notifyAll()
方法来唤醒等待的线程。通过这种方式,可以实现两个线程的串行执行。例如:
- Object lock = new Object();
-
- // 线程1
- synchronized (lock) {
- // 执行线程1的任务
- // 线程1执行完成后,唤醒等待的线程
- lock.notify();
- }
-
- // 线程2
- synchronized (lock) {
- // 等待线程1执行完成
- lock.wait();
- // 执行线程2的任务
- }
使用同步方法或同步代码块: 可以使用 synchronized 关键字来保证多个线程对共享资源的访问是同步的,从而实现线程的串行执行。例如:
- synchronized void thread1Task() {
- // 线程1的任务
- }
-
- synchronized void thread2Task() {
- // 线程2的任务
- }
通过以上方式,可以实现两个线程的串行执行,确保它们按照指定的顺序执行。选择合适的方法取决于具体的场景和需求。
在 Java 中,没有直接的方法可以在运行时“杀死”一个线程,也没有提供类似于 Thread.kill()
的方法。这是因为在 Java 中,线程的停止是基于协作和共享状态的,而不是通过直接终止线程的方式。
然而,你可以通过设置一个标志位,让线程在下一个合适的时机自行停止。例如,你可以使用一个 volatile boolean
类型的标志位,在线程执行的过程中定期检查该标志位,并在标志位变为 true
时自行停止线程。
下面是一个简单的示例:
- public class MyThread extends Thread {
- private volatile boolean running = true;
-
- public void stopThread() {
- running = false;
- }
-
- @Override
- public void run() {
- while (running) {
- // 线程执行的任务
- System.out.println("Thread is running...");
- try {
- Thread.sleep(1000); // 模拟线程执行任务的时间
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- System.out.println("Thread stopped.");
- }
-
- public static void main(String[] args) throws InterruptedException {
- MyThread thread = new MyThread();
- thread.start();
- Thread.sleep(5000); // 等待一段时间
- thread.stopThread(); // 设置标志位,停止线程
- }
- }
Synchronized
是 Java 中用于实现同步的关键字,它可以被用于方法、代码块、以及实例方法和静态方法。Synchronized
的主要目的是控制多个线程访问共享资源时的并发问题,确保线程之间的协调执行。在 Java 中,Synchronized
的实现原理主要基于对象头的 Mark Word 和 Monitor(监视器)。
对象头: 在 Java 对象的内存布局中,对象头包含了一些用于存储对象自身的运行时数据,其中的 Mark Word 就是其中的一部分。
Mark Word: Mark Word 存储了对象的 hashCode、分代年龄、锁标志等信息。其中,Synchronized
使用了 Mark Word 中的锁标志位来实现同步。
每个 Java 对象都与一个 Monitor 关联,用于实现对象级别的同步。
Monitor 中有两个队列:
Object.wait()
方法)而被挂起的线程。进入同步块: 当一个线程尝试进入一个同步代码块时,会首先尝试获取对象的锁。
锁的获取:如果对象的 Mark Word 中的锁标志位为 0,表示该对象没有被锁定,那么线程将尝试获取锁,并将锁标志位设置为线程的 ID。如果对象已经被其他线程锁定,那么当前线程会进入 EntryList 队列等待。
锁的释放:当线程退出同步块时,会释放对象的锁,将锁标志位清零。如果有其他线程在 EntryList 中等待,会选择其中一个线程唤醒,并将锁标志位设置为唤醒线程的 ID。
锁的升级:
总体而言,Synchronized
通过对对象头的 Mark Word 进行操作,以及通过 Monitor 进行锁的获取和释放,来实现对共享资源的同步控制。这种同步机制保证了对共享资源的互斥访问。需要注意的是,锁的升级过程旨在优化性能,避免过多的锁竞争。
背景知识了解
Java的线程抽象内存模型中定义了每个线程都有一份自己的私有内存,里面存放自己私有的数据,其他线程不能直接访问,而一些共享数据则存在主内存中,供所有线程进行访问。
上图中,如果线程A和线程B要进行通信,就要经过主内存,比如线程B要获取线程A修改后的共享变量的值,要经过下面两步:
(1)、线程A修改自己的共享变量副本,并刷新到了主内存中。
(2)、线程B读取主内存中被A更新过的共享变量的值,同步到自己的共享变量副本中。
(1)、原子性:是指线程的多个操作是一个整体,不能被分割,要么就不执行,要么就全部执行完,中间不能被打断。
(2)、可见性:是指线程之间的可见性,就是一个线程修改后的结果,其他的线程能够立马知道。
(3)、有序性:为了提高执行效率,java中的编译器和处理器可以对指令进行重新排序,重新排序会影响多线程并发的正确性,有序性就是要保证不进行重新排序(保证线程操作的执行顺序)。
volatile关键字的作用就是保证了可见性和有序性(不保证原子性),如果一个共享变量被volatile关键字修饰,那么如果一个线程修改了这个共享变量后,其他线程是立马可知的。
为什么是这样的呢?比如,线程A修改了自己的共享变量副本,这时如果该共享变量没有被volatile修饰,那么本次修改不一定会马上将修改结果刷新到主存中,如果此时B去主存中读取共享变量的值,那么这个值就是没有被A修改之前的值。如果该共享变量被volatile修饰了,那么本次修改结果会强制立刻刷新到主存中,如果此时B去主存中读取共享变量的值,那么这个值就是被A修改之后的值了。
volatile能禁止指令重新排序,在指令重排序优化时,在volatile变量之前的指令不能在volatile之后执行,在volatile之后的指令也不能在volatile之前执行,所以它保证了有序性。
synchronized提供了同步锁的概念,被synchronized修饰的代码段可以防止被多个线程同时执行,必须一个线程把synchronized修饰的代码段都执行完毕了,其他的线程才能开始执行这段代码。
因为synchronized保证了在同一时刻,只能有一个线程执行同步代码块,所以执行同步代码块的时候相当于是单线程操作了,那么线程的可见性、原子性、有序性(线程之间的执行顺序)它都能保证了。
volatile关键字和synchronized关键字的区别
(1)、volatile只能作用于变量,使用范围较小。synchronized可以用在变量、方法、类、同步代码块等,使用范围比较广。
(2)、volatile只能保证可见性和有序性,不能保证原子性。而可见性、有序性、原子性synchronized都可以保证。
(3)、volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
背景知识了解
Java语言的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另一个线程仍然可以访问该object中的非加锁代码块。
(1)synchronized的缺陷
synchronized是java中的一个关键字,也就是说是Java语言内置的特性。那么为什么会出现Lock呢?
如果一个代码块被synchronized修饰了,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待,等待获取锁的线程释放锁,而这里获取锁的线程释放锁只会有两种情况:
1)获取锁的线程执行完了该代码块,然后线程释放对锁的占有;
2)线程执行发生异常,此时JVM会让线程自动释放锁。
那么如果这个获取锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,其他线程便只能等待,试想一下,这多么影响程序执行效率。因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。
再举个例子:当有多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是采用synchronized关键字来实现同步的话,就会导致一个问题:如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,通过Lock就可以办到。
另外,通过Lock可以知道线程有没有成功获取到锁。这个是synchronized无法办到的。
总结一下,也就是说Lock提供了比synchronized更多的功能。但是要注意以下几点:
1)Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
2)Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。
(2)java.util.concurrent.locks包下常用的类
- public interface Lock {
- /*获取锁,如果锁被其他线程获取,则进行等待*/
- void lock();
-
- /**当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,
- 即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,
- 假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。*/
- void lockInterruptibly() throws InterruptedException;
-
- /**tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成
- *功,则返回true,如果获取失败(即锁已被其他线程获取),则返回
- *false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。*/
- boolean tryLock();
-
- /*tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,
- 只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。
- 如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。*/
- boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
- void unlock(); //释放锁
- Condition newCondition();
- }
注意:
当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为单独调用interrupt()方法不能中断正在运行过程中的线程,只能中断阻塞过程中的线程。而用synchronized修饰的话,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。
(3)ReentrantLock
ReentrantLock,意思是“可重入锁”,是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。
如果锁具备可重入性,则称作为可重入锁。像synchronized和ReentrantLock都是可重入锁,可重入性在我看来实际上表明了锁的分配机制:基于线程的分配,而不是基于方法调用的分配。
举个简单的例子,当一个线程执行到某个synchronized方法时,比如说method1,而在method1中会调用另外一个synchronized方法method2,此时线程不必重新去申请锁,而是可以直接执行方法method2。
- class MyClass {
- public synchronized void method1() {
- method2();
- }
-
- public synchronized void method2() {
- }
- }
synchronized和lock区别
1)Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
2)synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;
而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
3)Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
4)通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
5)Lock可以提高多个线程进行读操作的效率。
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
前提知识:Atomic 内部的value 使用volatile保证内存可见性,使用CAS保证原子性
打开AtomicInteger的源码可以看到:
- private static final Unsafe unsafe = Unsafe.getUnsafe();
- private volatile int value;
volatile关键字用来保证内存的可见性(但不能保证线程安全性),线程读的时候直接去主内存读,写操作完成的时候立即把数据刷新到主内存当中。
- /**
- * Atomically sets the value to the given updated value
- * if the current value {@code ==} the expected value.
- *
- * @param expect the expected value
- * @param update the new value
- * @return {@code true} if successful. False return indicates that
- * the actual value was not equal to the expected value.
- */
- public final boolean compareAndSet(int expect, int update) {
- return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
- }
从注释就可以看出:当线程写数据的时候,先对内存中要操作的数据保留一份旧值,真正写的时候,比较当前的值是否和旧值相同,如果相同,则进行写操作。如果不同,说明在此期间值已经被修改过,则重新尝试。
compareAndSet使用Unsafe调用native本地方法CAS(CompareAndSet)递增数值。CAS利用CPU调用底层指令实现。
AtomicInteger
、AtomicBoolean
等原子类之所以在高并发时高效,共同的原因是因为它们使用了硬件级别的原子操作来实现对变量的更新,避免了使用锁带来的性能开销和线程阻塞。
具体来说,它们的高效性可以归结为以下几个方面:
无锁机制:原子类通过底层的CAS(Compare and Swap)操作实现对变量的原子更新,而不需要使用显式的锁。CAS操作是一种硬件级别的原子操作,可以保证在多线程环境下对变量的安全访问和更新,避免了使用锁带来的性能开销和线程阻塞。
并发性能:原子类的实现通常基于CPU的原子指令(比如compareAndSet()
方法),在多核处理器上能够充分利用硬件并发性能,实现高效的并发访问。
无阻塞:由于原子类采用了无锁的方式实现对变量的更新,因此不存在线程阻塞的问题。即使有大量线程同时访问原子类中的变量,也不会导致线程的长时间阻塞等待锁资源释放,从而提高了系统的响应性和吞吐量。
综上所述,AtomicInteger
、AtomicBoolean
等原子类通过利用硬件级别的原子操作来实现对变量的原子更新,从而实现了高效的并发访问和线程安全性,成为了在高并发场景下常用的并发工具之一。
使用了 volatile 关键字的变量,每当变量的值有变动的时候,都会将更改立即同步到主内存中;而如果某个线程想要使用这个变量,就先要从主存中刷新到工作内存,这样就确保了变量的可见性。有了这个关键字的修饰,就能保证每次比较的时候,拿到的值总是最新的。
更详细的见文章:CAS技术分析 + 超越并发瓶颈:CAS与乐观锁的智慧应用
CAS(Compare and Swap)是一种并发编程中的原子操作,用于实现多线程环境下的无锁同步。它基于比较当前值与期望值的方式来更新变量的值,只有在当前值与期望值相等的情况下才进行更新,否则不进行更新。
CAS的主要缺陷是ABA问题。ABA问题指的是,在执行CAS操作期间,变量的值经过一系列的修改先变成了A,然后又被修改为B,最后又被修改回A。在这种情况下,CAS操作会错误地认为变量的值没有被其他线程修改过,导致操作成功,但实际上变量的值已经发生了变化。
为了解决ABA问题,可以采取以下两种方法:
Java中的Atomic类提供了基于CAS操作的原子类,如AtomicInteger、AtomicLong等。这些原子类已经内部处理了ABA问题,使用了类似版本号或标记的机制来解决ABA问题,从而提供了线程安全的原子操作。
需要注意的是,尽管CAS是一种无锁的同步机制,但在高并发场景下,由于CAS操作可能会多次失败和重试,从而导致性能下降。因此,在选择使用CAS时,需要根据具体场景综合考虑其性能和实现复杂度。
具体理解可见:超越并发瓶颈:CAS与乐观锁的智慧应用
直接以AtomicInteger
中 CAS 操作的原子性保证来进行理解。
AtomicInteger
类中的 compareAndSet
方法用于执行 CAS 操作,其代码如下:
- public final boolean compareAndSet(int expectedValue, int newValue) {
- return U.compareAndSetInt(this, VALUE, expectedValue, newValue);
- }
这里的 U
是 Unsafe
类的实例,VALUE
是内存偏移量。compareAndSetInt
是 Unsafe
类中的一个本地方法,直接调用底层的硬件指令来实现原子操作。
public final native boolean compareAndSetInt(Object o, long offset, int expected, int x);
Unsafe
类中 compareAndSetInt
方法的实现会调用 weakCompareAndSetInt
方法,该方法通过自旋重试实现CAS操作:
- public final int getAndAddInt(Object o, long offset, int delta) {
- int v;
- do {
- v = getIntVolatile(o, offset);
- } while (!weakCompareAndSetInt(o, offset, v, v + delta));
- return v;
- }
在这个方法中,getIntVolatile
获取当前值,weakCompareAndSetInt
尝试更新值,如果更新失败,则重复上述过程,直到成功,即自旋重试。
在 Linux 系统的 x86 架构上,CAS 操作最终会映射到 cmpxchgl
汇编指令,这是由 os_cpu/linux_x86/atomic_linux_x86.hpp
文件中的代码实现的:
- template<>
- template<typename T>
- inline T Atomic::PlatformCmpxchg<4>::operator()(T exchange_value,
- T volatile* dest,
- T compare_value,
- atomic_memory_order /* order */) const {
- STATIC_ASSERT(4 == sizeof(T));
- __asm__ volatile ("lock cmpxchgl %1,(%3)"
- : "=a" (exchange_value)
- : "r" (exchange_value), "a" (compare_value), "r" (dest)
- : "cc", "memory");
- return exchange_value;
- }
这里的 cmpxchgl
指令是关键。这条汇编指令的作用是:
EAX
中的值(compare_value
)和内存地址 dest
中的值。exchange_value
存储到 dest
中。dest
中的值加载到 EAX
中。lock
前缀确保了操作的原子性,这意味着在多处理器系统中,该指令在执行时会锁住总线或使用缓存一致性协议,保证其他处理器不能访问内存地址,直到操作完成。
在不同的硬件平台上,支持CAS操作的指令可能不同,但其基本原理是一致的:
- x86 平台:x86处理器提供了
CMPXCHG
指令来实现CAS操作。这个指令是原子的,即在执行过程中,不会被其他指令中断。- PowerPC 平台:PowerPC处理器提供了
lwarx
和stwcx.
指令组合来实现CAS操作,这些指令也确保了操作的原子性。- ARM 平台:ARM处理器提供了
LDREX
和STREX
指令组合来实现CAS操作。
硬件指令 cmpxchgl
结合 lock
前缀保证了在多处理器环境下的原子性,即整个比较和替换操作是不可分割的,这就是 CAS 操作能够实现原子性的原因。
更详细的见文章:可重入锁 VS 非可重入锁
可重入锁(Reentrant Lock)和不可重入锁(Non-reentrant Lock)是锁的两种不同实现方式,其主要区别在于是否支持同一个线程多次获取同一把锁。
可重入锁允许同一个线程多次获取同一把锁,而不可重入锁不允许同一个线程多次获取同一把锁。具体来说,可重入锁会维护一个获取锁的计数器,每次成功获取锁时,计数器会加1;线程释放锁时,计数器会减1。只有当计数器归零时,其他线程才能获取该锁。这样,同一个线程在持有锁的情况下,可以再次获取同一把锁而不会被阻塞,称为锁的重入性。
不可重入锁则不支持同一个线程多次获取同一把锁。当一个线程已经持有该锁时,再次尝试获取同一把锁会导致线程被阻塞,直到其他线程释放该锁。
理解可重入锁和不可重入锁的区别有助于避免死锁和实现复杂的同步逻辑。可重入锁能够适应更复杂的同步需求,允许在同一线程中递归地调用同步方法或代码块,而不可重入锁则需要谨慎使用,以防止死锁和逻辑错误。
在Java中,synchronized关键字实现的锁是可重入锁,即同一个线程在持有锁的情况下可以再次获取同一把锁。而ReentrantLock类也是可重入锁的实现,它提供了更多灵活性和扩展性,可以用于更复杂的同步场景。
在Java中,锁的升级是指在多线程竞争的情况下,从低级别的锁逐渐升级到高级别的锁。Java的锁升级过程包括无锁、偏向锁、轻量级锁和重量级锁,每个级别的锁都有不同的开销和适用场景。
锁的升级过程是动态的,根据竞争情况和线程访问模式来进行判断和转换。如果竞争激烈,锁会很快升级为重量级锁;如果竞争较小或仅有一个线程访问,锁可能一直保持为偏向锁。锁升级的过程会带来一定的开销,因此,在设计多线程应用程序时,需要综合考虑锁的升级过程和并发性能的平衡。需要注意的是,锁的升级是由Java虚拟机自动进行的,开发人员无需显式控制。锁升级机制的目标是提供更好的并发性能和适应不同的多线程竞争场景。
更详细的见文章:无锁 VS 偏向锁 VS 轻量级锁 VS 重量级锁
更详细的见文章:乐观锁 VS 悲观锁?公平锁 VS 非公平锁?独享锁 VS 共享锁?
更详细的见文章:Java中常用的锁总结与理解
主要内容和介绍具体可见:超越并发瓶颈:CAS与乐观锁的智慧应用
乐观锁适用于读多写少的情况的原因
悲观锁在读多写少的情况下也有冲突少的特点,为什么不适合呢?
尽管悲观锁在读多写少的情况下可能会有较少的冲突,但它的主要问题在于加锁这个动作上:
尽管悲观锁在一些情况下也能够处理并发问题,但在读多写少的情况下,乐观锁更适合,因为它更符合读多写少的特点,可以更好地实现读操作的并发执行,提高系统的性能。
死锁是指在并发系统中,两个或多个进程(或线程)因为争夺资源而被永久地阻塞,导致系统无法继续执行的状态。以下是导致死锁发生的常见原因:
当这四个条件同时满足时,就可能发生死锁。当系统进入死锁状态后,没有外部干预,系统将无法恢复正常。
为了避免死锁的发生,可以采取以下策略:
死锁是一种复杂的并发问题,需要细心的设计和合理的资源管理来避免。在实际开发中,可以使用死锁检测、死锁避免、死锁恢复等技术手段来处理死锁问题。
死锁是一个并发编程中常见的问题,它发生在两个或更多线程互相持有对方所需要的资源而无法继续执行的情况。下面是用 Java 代码实现一个简单的死锁示例:
- package org.zyf.javabasic.test.thread;
-
- /**
- * @program: zyfboot-javabasic
- * @description: 死锁用例
- * @author: zhangyanfeng
- * @create: 2023-08-13 22:37
- **/
- public class DeadlockExample {
- private static Object resource1 = new Object();
- private static Object resource2 = new Object();
-
- public static void main(String[] args) {
- Thread thread1 = new Thread(() -> {
- synchronized (resource1) {
- System.out.println("Thread 1: Holding resource 1...");
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("Thread 1: Waiting for resource 2...");
- synchronized (resource2) {
- System.out.println("Thread 1: Acquired resource 2!");
- }
- }
- });
-
- Thread thread2 = new Thread(() -> {
- synchronized (resource2) {
- System.out.println("Thread 2: Holding resource 2...");
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("Thread 2: Waiting for resource 1...");
- synchronized (resource1) {
- System.out.println("Thread 2: Acquired resource 1!");
- }
- }
- });
-
- thread1.start();
- thread2.start();
- }
- }
在这个示例中,两个线程(thread1 和 thread2)分别持有 resource1 和 resource2,并试图获取对方的资源。由于每个线程都在等待另一个线程释放资源,因此这段代码会导致死锁。
解决死锁问题需要采取一些常见的方法和策略,以确保线程在并发执行时不会发生死锁。以下是一些解决死锁问题的方法:
请注意,死锁问题可能比较复杂,解决方法需要根据具体的代码和场景来确定。在设计并发程序时,要注意多线程之间的资源竞争和互斥关系,合理地选择锁和同步方式,并进行充分的测试和验证,以确保程序在运行时不会出现死锁问题。
在上面提供的死锁代码示例中,可以通过改变锁的获取顺序来解决死锁问题。确保线程在获取锁时按照相同的顺序来避免循环等待。具体来说,可以修改线程2的代码,将它的锁获取顺序与线程1相同,从而避免死锁。
下面是修改后的代码示例:
- package org.zyf.javabasic.test.thread;
-
- /**
- * @program: zyfboot-javabasic
- * @description: 死锁用例解决
- * @author: zhangyanfeng
- * @create: 2023-08-13 22:41
- **/
- public class DeadlockDealExample {
- private static Object resource1 = new Object();
- private static Object resource2 = new Object();
-
- public static void main(String[] args) {
- Thread thread1 = new Thread(() -> {
- synchronized (resource1) {
- System.out.println("Thread 1: Holding resource 1...");
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("Thread 1: Waiting for resource 2...");
- synchronized (resource2) {
- System.out.println("Thread 1: Acquired resource 2!");
- }
- }
- });
-
- Thread thread2 = new Thread(() -> {
- synchronized (resource1) {
- System.out.println("Thread 2: Holding resource 1...");
- try {
- Thread.sleep(100);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- System.out.println("Thread 2: Waiting for resource 2...");
- synchronized (resource2) {
- System.out.println("Thread 2: Acquired resource 2!");
- }
- }
- });
-
- thread1.start();
- thread2.start();
- }
- }
通过将线程2的锁获取顺序调整为先获取resource1,再获取resource2,就能够避免死锁。当线程1持有resource1时,线程2无法获取resource1,从而避免了相互等待对方资源的情况,解决了死锁问题。
使用 ExecutorService
实现让10个任务同时并发启动:
- import java.util.concurrent.ExecutorService;
- import java.util.concurrent.Executors;
-
- public class ConcurrentTasks {
- public static void main(String[] args) {
- int numberOfTasks = 10;
- ExecutorService executor = Executors.newFixedThreadPool(numberOfTasks);
-
- for (int i = 0; i < numberOfTasks; i++) {
- Runnable task = new Task("Task " + (i + 1));
- executor.execute(task);
- }
-
- executor.shutdown();
- }
-
- static class Task implements Runnable {
- private final String name;
-
- public Task(String name) {
- this.name = name;
- }
-
- @Override
- public void run() {
- System.out.println("Executing " + name);
- // 在这里放置任务的逻辑
- }
- }
- }
创建了一个固定大小的线程池,大小为10,然后循环创建10个任务,并使用 executor.execute(task)
方法将任务提交到线程池中执行。由于线程池的大小为10,因此这10个任务可以同时并发启动执行。
更详细的见文章:从ReentrantLock理解AQS的原理及应用总结
AQS全称为AbstractQueuedSynchronizer,是Java中用于构建锁和同步器的框架性组件,它是Java并发包中ReentrantLock、Semaphore、ReentrantReadWriteLock等同步器的基础。AQS的设计思想是,在其内部维护了一个双向队列,用于管理请求锁的线程。当有线程请求锁时,AQS会将其封装成一个Node节点,并加入到等待队列中,线程则会进入阻塞状态。当持有锁的线程释放锁时,AQS会从等待队列中唤醒一个线程来获取锁,从而实现线程的同步和互斥。
AQS的主要特点包括:
AQS的实现被广泛应用于Java并发包中的各种同步器,如ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch等。AQS为这些同步器提供了一个统一的基础框架,并且可以让开发人员基于此进行扩展和定制化。
AQS内部有3个对象,一个是state(用于计数器,类似gc的回收计数器),一个是线程标记(当前线程是谁加锁的),一个是阻塞队列。
它内部实现主要是状态变量state和一个FIFO队列来完成,同步队列的头结点是当前获取到同步状态的结点,获取同步状态state失败的线程,会被构造成一个结点(或共享式或独占式)加入到同步队列尾部(采用自旋CAS来保证此操作的线程安全),随后线程会阻塞;释放时唤醒头结点的后继结点,使其加入对同步状态的争夺中。
底层公平锁和非公平锁的原理涉及到 ReentrantLock
内部的同步器 Sync
的实现。底层原理:
在非公平锁模式下,ReentrantLock
使用的同步器是 NonfairSync
类。NonfairSync
内部使用了 CAS 操作,通过 compareAndSetState
方法来修改锁的状态,尝试直接获取锁。如果获取失败,线程会进入等待队列并尝试重新获取锁。
在公平锁模式下,ReentrantLock
使用的同步器是 FairSync
类。FairSync
实现了一种公平的获取锁的机制。当线程尝试获取锁时,如果发现队列中已经有等待的线程,会将当前线程加入到等待队列中,然后进入自旋等待直到获得锁。
无论是公平锁还是非公平锁,它们的底层同步器都基于 AbstractQueuedSynchronizer
(AQS)实现。AQS 提供了一个框架,用于构建基于队列的同步器。ReentrantLock
则在 AQS 的基础上实现了重入锁的语义。理解 AbstractQueuedSynchronizer
(AQS)对于理解 ReentrantLock
的底层原理是至关重要的。AQS 是一个用于构建锁和其他同步器的框架,它提供了一种基于 FIFO 等待队列的机制,用于管理线程的获取和释放资源。
状态管理: AQS 使用一个 int
类型的变量来表示同步状态。这个状态可以被不同的同步器进行修改和检查。比如,ReentrantLock
中的状态表示锁的持有次数。
等待队列: AQS 使用一个等待队列(CLH
队列)来维护等待线程。这个队列是一个虚拟的双向链表,每个节点代表一个等待线程,按照 FIFO 的顺序进行排队。
原子性操作: AQS 提供了一些原子性的操作,比如 getState
、setState
、compareAndSetState
等,这些操作基于 Unsafe
类的 CAS 操作。
独占锁与共享锁: AQS 支持独占锁和共享锁两种模式。ReentrantLock
就是一个独占锁的典型例子,而 CountDownLatch
可以用作共享锁的例子。
模板方法: AQS 是一个框架,它定义了一些模板方法,其中最为重要的是 tryAcquire
和 tryRelease
。这两个方法需要被子类重写以实现具体的同步逻辑。在 ReentrantLock
中,这两个方法分别对应着获取锁和释放锁的逻辑。
使用 sleep
主要是为了线程休眠,不考虑锁的释放和唤醒的问题。
使用 wait
主要是为了线程等待,并通常与锁和条件结合使用,需要在同步块或同步方法中调用。
sleep
和 wait
是多线程编程中用于线程等待的两种不同机制,它们的主要区别在于使用的上下文、作用对象以及条件触发等方面。
调用的上下文:
sleep
: sleep
是 Thread
类的静态方法,直接通过线程对象调用。它不会释放持有的锁,即使当前线程持有某个对象的锁,调用 sleep
后也不会释放该锁。
wait
: wait
是 Object
类的实例方法,需要在对象的同步块或同步方法中调用。调用 wait
会释放对象的锁,并使当前线程进入等待状态,直到其他线程调用相同对象的 notify
或 notifyAll
方法唤醒它。
作用对象:
sleep
: sleep
是线程级别的,它不依赖于任何对象,直接通过线程对象调用。
wait
: wait
是对象级别的,它必须在同步块或同步方法中调用,作用于当前对象。线程会等待其他线程调用相同对象的 notify
或 notifyAll
方法来唤醒它。
条件触发:
sleep
: sleep
会在指定的时间内阻塞当前线程,不依赖于外部条件的变化。即使指定的时间到达,也不会被其他线程主动唤醒。
wait
: wait
会阻塞当前线程,并且需要等待其他线程通过相同对象的 notify
或 notifyAll
方法来唤醒。通常,wait
会与某个条件结合使用,即在等待之前检查某个条件,等待满足条件时才继续执行。
错误使用的情况:
sleep
: 如果在同步块或同步方法中使用 sleep
,它不会释放锁,可能会导致其他线程无法进入同步块。
wait
: 如果在没有持有锁的情况下调用 wait
,会抛出 IllegalMonitorStateException
异常。
在 Java 中,notify()
和 notifyAll()
都是用于线程间通信的方法,用于唤醒等待在对象监视器上的线程。它们之间的主要区别在于:
notify()
方法:
notify()
方法用于唤醒在当前对象的监视器上等待的单个线程。如果有多个线程等待在同一个对象的监视器上,那么只会唤醒其中一个线程,但是具体唤醒哪个线程是不确定的,取决于 JVM 的实现。notifyAll()
方法:
notifyAll()
方法用于唤醒在当前对象的监视器上等待的所有线程。如果有多个线程等待在同一个对象的监视器上,那么所有等待的线程都会被唤醒。因此,notify()
方法只唤醒一个线程,而 notifyAll()
方法会唤醒所有等待的线程。通常情况下,当多个线程等待同一个条件变量时,应该使用 notifyAll()
方法来确保所有等待的线程都被唤醒,以避免发生死锁或者部分线程被遗漏的情况。
除了使用 Object.wait()
和 Object.notifyAll()
方法来实现线程间的交互外,还可以使用以下几种方式:
使用Lock和Condition:通过 java.util.concurrent.locks.Lock
接口和 java.util.concurrent.locks.Condition
接口提供的方法来实现线程间的协调和通信。使用 Condition
的 await()
和 signalAll()
方法可以代替 Object.wait()
和 Object.notifyAll()
方法。
使用CountDownLatch:java.util.concurrent.CountDownLatch
是一种同步工具类,它可以使一个或多个线程等待其他线程完成操作后再执行。通过调用 CountDownLatch
的 await()
和 countDown()
方法可以实现线程间的等待和触发。
使用CyclicBarrier:java.util.concurrent.CyclicBarrier
也是一种同步工具类,它可以使一组线程相互等待,直到所有线程都到达某个屏障点后再继续执行。通过调用 CyclicBarrier
的 await()
方法可以实现线程间的等待和同步。
使用Semaphore:java.util.concurrent.Semaphore
是一种计数信号量,它可以限制同时访问某个资源的线程数量。通过调用 Semaphore
的 acquire()
和 release()
方法可以实现线程的互斥和同步。
使用BlockingQueue:java.util.concurrent.BlockingQueue
是一种线程安全的队列,它提供了阻塞式的读写操作。通过将 BlockingQueue
作为线程间的共享数据结构,可以实现线程间的安全通信。
了解ThreadLocal
典型的应用场景
ThreadLocal实现原理
ThreadLocal
使用弱引用的主要目的是为了防止内存泄漏。在多线程环境中,如果没有正确处理 ThreadLocal
的引用关系,可能导致线程结束后,ThreadLocal
对象及其对应的值无法被垃圾回收,从而造成内存泄漏。
强引用时的问题:如果 ThreadLocal
使用强引用,当一个线程持有 ThreadLocal
对象,并且该线程长时间存活,那么 ThreadLocal
对象及其对应的值将一直存在于内存中。
即使该线程结束,ThreadLocal
对象对应的值在 ThreadLocalMap
中仍然存在,因为 ThreadLocalMap
是线程的一个字段,会一直存在于内存中。
线程结束时的清理问题:如果一个线程结束,但没有显式调用 ThreadLocal
的 remove
方法来清理对应的值,那么这部分内存将一直被占用。
在长时间运行的服务或应用中,可能会创建大量的 ThreadLocal
实例,如果不及时清理,可能会导致大量内存泄漏。
使用弱引用可以解决上述内存泄漏问题:
弱引用特点:弱引用在垃圾回收时会被更容易地回收。如果一个对象只被弱引用引用,而没有被强引用引用,那么在下一次垃圾回收时,这个对象就会被回收。
ThreadLocalMap.Entry 使用弱引用:ThreadLocalMap.Entry
是 ThreadLocalMap
中的元素,其中的 ThreadLocal
使用弱引用。当 ThreadLocal
对象被垃圾回收时,对应的 Entry
也会被回收。
解决内存泄漏:当线程结束时,ThreadLocalMap
会被回收,其中的弱引用 ThreadLocal
对象也会被回收,从而避免了内存泄漏。
总体而言,使用弱引用可以帮助 ThreadLocal
更及时地释放其引用的对象,从而避免因长时间保持引用而导致的内存泄漏问题。
ThreadLocal
可能导致内存泄漏的情况通常是由于没有及时清理 ThreadLocal
引用导致的。以下是一些帮助避免 ThreadLocal
导致的内存泄漏问题:
1.显式调用 remove
方法:在不再需要使用 ThreadLocal
存储的数据时,建议显式调用 remove
方法,将 ThreadLocal
与其对应的值从当前线程的 ThreadLocalMap
中移除。
2.使用 try-with-resources(Java 7+):在 Java 7 及更高版本中,可以使用 try-with-resources 语句来自动管理资源,包括 ThreadLocal
的清理。
3.使用弱引用:ThreadLocal
自身在实现上使用了弱引用,但如果存储的值是强引用,仍然可能导致内存泄漏。尽量存储使用弱引用引用的对象,或者确保在不需要时及时清理引用。
4.使用静态内部类:如果需要使用 ThreadLocal
在静态范围内存储值,可以考虑使用静态内部类,并将 ThreadLocal
定义为该内部类的静态成员。这样可以避免直接持有外部类的引用,降低内存泄漏的风险。
5.使用框架:某些框架和库提供了专门用于解决 ThreadLocal
内存泄漏问题的解决方案,例如 ThreadLocalCleaner 等。
注意:每次线程结束时,ThreadLocalMap
应该会被自动清理,但在某些情况下(例如线程池),线程可能不会立即终止,因此需要额外的注意来防止内存泄漏。
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
Java的线程池是运用场景最多的并发框架,几乎所有需要异步或者并发执行任务的程序都可以使用线程池。
合理使用线程池能带来的好处:
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
线程池的线程数量怎么确定
线程池的五种运行状态
RUNNING : 该状态的线程池既能接受新提交的任务,又能处理阻塞队列中任务。
SHUTDOWN:该状态的线程池**不能接收新提交的任务**,**但是能处理阻塞队列中的任务**。处于 RUNNING 状态时,调用 shutdown()方法会使线程池进入到该状态。
注意: finalize() 方法在执行过程中也会隐式调用shutdown()方法。
STOP: 该状态的线程池不接受新提交的任务,也不处理在阻塞队列中的任务,还会中断正在执行的任务。在线程池处于 RUNNING 或 SHUTDOWN 状态时,调用 shutdownNow() 方法会使线程池进入到该状态;
TIDYING: 如果所有的任务都已终止,workerCount (有效线程数)=0 。线程池进入该状态后会调用 terminated() 钩子方法进入TERMINATED 状态。
TERMINATED: 在terminated()钩子方法执行完后进入该状态,默认terminated()钩子方法中什么也没有做。
线程池的关闭(shutdown或者shutdownNow方法)
可以通过调用线程池的shutdown或者shutdownNow方法来关闭线程池:遍历线程池中工作线程,逐个调用interrupt方法来中断线程。
shutdown方法与shutdownNow的特点:
在Java中,线程的优先级可以通过设置线程的优先级属性来控制。线程池中的线程也可以通过设置优先级来调整其执行顺序。以下是设置线程池线程优先级的一般步骤:
创建线程池对象:首先,使用Executors类或ThreadPoolExecutor类创建一个线程池对象。
ExecutorService executor = Executors.newFixedThreadPool(10);
自定义线程工厂:通过实现ThreadFactory接口,自定义一个线程工厂类,用于创建线程对象并设置线程的优先级。
- class CustomThreadFactory implements ThreadFactory {
- @Override
- public Thread newThread(Runnable r) {
- Thread t = new Thread(r);
- t.setPriority(Thread.MAX_PRIORITY); // 设置线程优先级
- return t;
- }
- }
创建线程池并设置线程工厂:使用自定义的线程工厂类创建线程池对象,并将其设置为线程池的线程工厂。
- ExecutorService executor = Executors
- .newFixedThreadPool(10, new CustomThreadFactory());
通过以上步骤,线程池中的线程将使用自定义的线程工厂来创建,从而可以设置线程的优先级。在上述示例中,将线程优先级设置为Thread.MAX_PRIORITY,也可以根据需求设置其他优先级,如Thread.MIN_PRIORITY或Thread.NORM_PRIORITY。
需要注意的是,线程的优先级并不是绝对的,它只是给调度器一个提示,告诉它线程的相对重要性。实际的线程调度行为还受到操作系统和底层硬件的影响。因此,不能过度依赖线程的优先级来控制程序的执行顺序和性能。
此外,需要注意的是,在使用线程池时,线程的优先级可能被线程池管理器调整,以便更好地管理线程的执行顺序和资源利用。因此,在设置线程池中线程的优先级时,需要结合具体的场景和需求来评估其影响。
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
可以通过ThreadPoolExecutor
来创建一个线程池,先上代码吧:
- new ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime,
- TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler)
常用的5个,核心池、最大池、空闲时间、时间的单位、阻塞队列;另外两个:拒绝策略、线程工厂类
具体详细说明:
corePoolSize(线程池的基本大小):
maximumPoolSize(线程池的最大数量): 线程池允许创建的最大线程数。
workQueue(工作队列): 用于保存等待执行的任务的阻塞队列。
keepAliveTime(线程活动保持时间): 线程池的工作线程空闲后,保持存活的时间。如果任务多而且任务的执行时间比较短,可以调大keepAliveTime,提高线程的利用率。
unit(线程活动保持时间的单位): 可选单位有DAYS、HOURS、MINUTES、毫秒、微秒、纳秒。
handler(饱和策略,或者又称拒绝策略): 当队列和线程池都满了,即线程池饱和了,必须采取一种策略处理提交的新任务。
threadFactory: 构建线程的工厂类
在这样创建的线程池中,当已经有10个任务在运行时,第11个任务提交到此线程池执行时,会发生以下情况:
因为线程池的核心线程数为10,最大线程数为100,所以前10个任务会立即启动并被线程池中的10个核心线程执行。
第11个任务会被放入线程池的任务队列中,即LinkedBlockingQueue
中。
由于任务队列的大小为10,而此时已经有10个任务在队列中等待执行,所以第11个任务会被成功添加到队列中。
如果任务队列被填满,并且当前线程池中的线程数量还未达到最大线程数(100),则会创建新的线程执行任务。
如果任务队列已满,并且当前线程池中的线程数量已经达到最大线程数(100),则新的任务将无法被提交到线程池中,并且会根据线程池的拒绝策略进行处理。默认情况下,线程池的默认拒绝策略是抛出 RejectedExecutionException
异常。
总之,当已经有10个任务在运行时,第11个任务提交到此线程池执行时,如果任务队列未满,则任务会被成功添加到队列中;如果任务队列已满,并且线程池中的线程数量已经达到最大线程数,则根据线程池的拒绝策略进行处理。
实现一个自定义的 ThreadFactory
的作用通常包括以下几个方面:
命名线程:自定义的 ThreadFactory
可以为线程设置有意义的名称,使得在日志和调试信息中能够清晰地识别线程的作用和来源。这样有助于跟踪线程的执行情况和定位问题。
设置线程属性:通过自定义的 ThreadFactory
,可以为线程设置一些属性,如线程的优先级、是否为守护线程等,以满足特定的需求。
创建定制化的线程:自定义的 ThreadFactory
可以根据应用的需求创建定制化的线程,如自定义的异常处理器、线程组等,以增强线程的管理和控制能力。
封装线程创建过程:通过自定义的 ThreadFactory
,可以封装线程的创建过程,使得应用代码与线程创建逻辑解耦,提高代码的可维护性和可扩展性。
统一管理线程:使用自定义的 ThreadFactory
可以统一管理应用中所有线程的创建,集中处理线程的创建逻辑和管理策略,方便进行统一的管理和调整。
总的来说,自定义的 ThreadFactory
主要作用是为了提供更加灵活和定制化的线程创建和管理功能,使得应用能够更好地满足特定的需求,并提高线程的可观察性、可控性和可维护性。
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
常见的线程池创建主要依赖于 java.util.concurrent
包提供的 Executors
工厂类,同时需要根据任务性质和工作负载来选择合适的线程池参数。以下是一些常见线程池的创建和参数分析:1. FixedThreadPool(固定大小的线程池):
ExecutorService executor = Executors.newFixedThreadPool(5);
2. CachedThreadPool(缓存线程池):
ExecutorService executor = Executors.newCachedThreadPool();
3. SingleThreadExecutor(单一线程池):
ExecutorService executor = Executors.newSingleThreadExecutor();
4. ScheduledThreadPool(定时任务线程池):
ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(3);
5. ThreadPoolExecutor(自定义线程池):
- ThreadPoolExecutor executor = new ThreadPoolExecutor(
- corePoolSize, // 核心线程数
- maximumPoolSize, // 最大线程数
- keepAliveTime, // 线程空闲时间
- TimeUnit.SECONDS, // 时间单位
- new LinkedBlockingQueue<>(), // 工作队列
- Executors.defaultThreadFactory(), // 线程工厂
- new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
- );
corePoolSize
:核心线程数,线程池维护的最小线程数。maximumPoolSize
:最大线程数,线程池维护的最大线程数。keepAliveTime
:线程空闲时间,非核心线程在空闲时的最大存活时间。TimeUnit
:时间单位。workQueue
:工作队列,存储未执行任务的队列。threadFactory
:线程工厂,用于创建线程。handler
:拒绝策略,用于处理任务无法被执行的情况。参数选择注意事项
workQueue
的选择要根据任务提交速度和处理速度的差异,选择合适的阻塞队列。不同的线程池适用于不同的场景,根据具体需求进行选择。在实际应用中,参数的调优通常需要结合系统资源状况和任务的特性进行综合考虑。
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
基本背景思路:
一个新的任务到线程池时,线程池的处理流程如下:
ThreadPoolExecutor类
具体的处理流程:
线程池的核心实现类是ThreadPoolExecutor类
,用来执行提交的任务。因此,任务提交到线程池时,具体的处理流程是由ThreadPoolExecutor类
的execute()方法去完成的。
ScheduledThreadPoolExecutor
继承自ThreadPoolExecutor,使用的工作队列是 DelayedWorkQueue
。DelayedWorkQueue
是 DelayedQueue
的一个实现,它继承自 AbstractQueue
,而后者实现了基本的队列操作。
在 ScheduledThreadPoolExecutor
中,DelayedWorkQueue
用于存储实现了 ScheduledFuture
接口的任务,其中的任务具有延迟执行或定期执行的特性。这样的任务会按照它们的延迟时间或周期进行排序。
内部任务排序机制
按照延迟时间排序:
DelayedWorkQueue
内部维护了一个有序的优先级队列(PriorityQueue),按照任务的延迟时间进行排序。按照周期性任务的下次执行时间排序:
通过这种排序机制,ScheduledThreadPoolExecutor
能够按照任务的延迟时间或者下次执行时间来执行任务,确保任务按照预期的时间顺序执行。
值得注意的是,DelayedWorkQueue
作为有界队列,可以配置最大容量,以控制任务的排队数量。如果队列已满,新的任务可能会导致拒绝策略的触发。
线程池是一种用于管理和调度线程的机制,它可以有效地管理线程的创建、执行和销毁。下面是线程池的运行逻辑以及FixedThreadPool和CachedThreadPool的原理。
线程池的运行逻辑:
FixedThreadPool的原理:
FixedThreadPool是一种固定大小的线程池,它会在初始化时创建指定数量的线程,并且线程数不会改变。它的原理是:
CachedThreadPool的原理:
CachedThreadPool是一种根据需要自动调整线程数量的线程池,它的原理是:
总而言之,FixedThreadPool和CachedThreadPool是两种常见的线程池实现。FixedThreadPool适用于需要固定线程数的场景,而CachedThreadPool适用于任务量不确定的场景,它会根据需求动态调整线程数量以提高系统的性能。
使用Executors.newCachedThreadPool()
创建的线程池是一个可缓存的线程池,它会根据需要动态地创建新线程,如果线程在60秒内未被使用就会被终止并从池中移除。虽然这种线程池具有灵活性和高效性,但也存在一些潜在的风险和问题:
线程数量不受限制:newCachedThreadPool()
创建的线程池没有固定的线程数限制,理论上可以创建大量的线程,如果并发请求过多,可能会导致服务器资源不足,出现内存溢出或者CPU过载等问题。
长时间运行的任务可能导致内存泄漏:由于线程池会在一定时间内清理未使用的线程,长时间运行的任务可能会导致线程被长时间占用,无法及时回收,从而导致内存泄漏。
任务执行时间不可控:线程池中的线程数量是动态调整的,任务的执行时间可能受到线程池中其他任务的影响,如果某些任务执行时间较长,可能会影响其他任务的执行效率。
可能导致任务排队过多:当任务提交速度大于线程池处理速度时,任务会被放入任务队列中等待执行,如果任务队列过长,可能会导致系统资源耗尽,造成系统性能下降或者宕机。
线程生命周期不受控制:由于线程池中的线程是可缓存的,因此线程的生命周期由线程池管理,无法手动控制线程的生命周期,可能会导致资源管理不当或者任务执行异常处理不及时。
基于以上风险,使用newCachedThreadPool()
时需要注意合理调整任务提交速度和任务执行时间,避免出现资源不足或者任务排队过多的情况,同时需要注意及时处理长时间运行的任务和异常情况,以保障系统的稳定性和性能。
ArrayBlockingQueue和LinkedBlockingQueue都是Java中常见的阻塞队列实现,它们都提供了线程安全的队列操作,并且支持在队列为空或已满时的阻塞操作。
ArrayBlockingQueue
LinkedBlockingQueue
两者的选择
需要注意的是,无界队列可能会在持续添加元素时耗尽系统的内存资源,因此在选择队列实现时要根据场景和需求进行权衡和选择。
CustomProducerConsumer
类封装了生产者消费者模型的实现细节,使用了 wait()
和 notifyAll()
方法来实现线程之间的等待和通知。生产者通过调用 produce()
方法往缓冲区中放入数据,消费者通过调用 consume()
方法从缓冲区中取出数据。
- import java.util.LinkedList;
- import java.util.Queue;
-
- class CustomProducerConsumer {
- private final Queue<Integer> buffer;
- private final int capacity;
-
- public CustomProducerConsumer(int capacity) {
- this.capacity = capacity;
- this.buffer = new LinkedList<>();
- }
-
- public void produce(int value) throws InterruptedException {
- synchronized (this) {
- while (buffer.size() == capacity) {
- wait();
- }
- buffer.offer(value);
- System.out.println("Produced: " + value);
- notifyAll();
- }
- }
-
- public int consume() throws InterruptedException {
- synchronized (this) {
- while (buffer.isEmpty()) {
- wait();
- }
- int value = buffer.poll();
- System.out.println("Consumed: " + value);
- notifyAll();
- return value;
- }
- }
- }
-
- class Producer implements Runnable {
- private final CustomProducerConsumer pc;
-
- public Producer(CustomProducerConsumer pc) {
- this.pc = pc;
- }
-
- @Override
- public void run() {
- for (int i = 0; i < 10; i++) {
- try {
- pc.produce(i);
- Thread.sleep(100); // 模拟生产时间
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- }
-
- class Consumer implements Runnable {
- private final CustomProducerConsumer pc;
-
- public Consumer(CustomProducerConsumer pc) {
- this.pc = pc;
- }
-
- @Override
- public void run() {
- for (int i = 0; i < 10; i++) {
- try {
- int value = pc.consume();
- Thread.sleep(200); // 模拟消费时间
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- }
- }
- }
-
- public class Main {
- public static void main(String[] args) {
- CustomProducerConsumer pc = new CustomProducerConsumer(5); // 缓冲区容量为5
-
- Thread producerThread = new Thread(new Producer(pc));
- Thread consumerThread = new Thread(new Consumer(pc));
-
- producerThread.start();
- consumerThread.start();
- }
- }
线程池的关闭原理涉及到线程池的生命周期管理和任务处理的终止过程。下面是线程池关闭的一般原理:
需要注意的是,线程池的关闭并不会立即停止所有的任务。已经在执行的任务需要等待其执行完成,而等待队列中的任务可以选择是否继续等待执行或者被丢弃。同时,如果线程池中的任务存在依赖关系,需要注意任务之间的处理顺序,以免产生不可预期的结果。
在使用线程池时,建议在合适的时机进行关闭操作,以确保资源的正确释放和程序的正常终止。可以通过适当的方式监听线程池的关闭状态,以便在需要时进行后续的处理。
在 Java 并发编程中,JUC(Java Util Concurrent)包提供了一些常见的类,例如 BlockingQueue
接口和其实现类 ArrayBlockingQueue
、LinkedBlockingQueue
、PriorityBlockingQueue
等。这些类通常用于多线程之间的数据交换和协调。
take()
和 poll()
方法的区别:
take()
方法用于从队列中取出元素,如果队列为空,则会阻塞线程,直到队列中有元素可以取出。poll()
方法也用于从队列中取出元素,如果队列为空,则会立即返回 null
,不会阻塞线程。put()
和 offer()
方法的区别:
put()
方法用于向队列中添加元素,如果队列已满,则会阻塞线程,直到队列有空闲位置可以添加元素。offer()
方法也用于向队列中添加元素,如果队列已满,则会立即返回 false
,不会阻塞线程。简要总结一下:
take()
和 put()
方法是阻塞的,会等待队列状态满足条件再执行。poll()
和 offer()
方法是非阻塞的,立即返回结果,不会等待队列状态改变。更详细的见文章:CompletableFuture回调机制的设计与实现
Future的实现原理就是通过Future和FutureTask接口,将任务封装成一个异步操作,并在主线程中等待任务完成后获取执行结果。FutureTask是Future的一个具体实现,通过阻塞方法和回调函数来实现异步操作的结果获取。
虽然Future在Java中提供了一种简单的异步编程技术,但它也存在一些局限性,包括以下几个方面:
综上所述,Future虽然提供了一种简单的异步编程技术,但它的局限性也是比较明显的。在实际应用中,我们需要根据具体的业务需求和性能要求,选择合适的异步编程技术。例如,可以使用CompletableFuture来解决Future的一些问题,它可以避免阻塞、支持异常处理和组合操作等功能。
CompletableFuture原理总述与回调机制总结
CompletableFuture是Java 8中引入的一个强大的异步编程工具,它允许我们以非阻塞的方式处理异步操作,并通过回调函数来处理异步操作完成后的结果。
CompletableFuture的核心原理是基于Java的Future接口和内部的状态机实现的。它可以通过三个步骤来实现异步操作:
CompletableFuture的优势在于它支持链式调用和组合操作。通过CompletableFuture的then系列方法,我们可以创建多个CompletableFuture对象,并将它们串联起来形成一个链式的操作流。在这个操作流中,每个CompletableFuture对象都可以依赖于之前的CompletableFuture对象,以实现更加复杂的异步操作。
总的来说,CompletableFuture的原理是基于Java的Future接口和内部的状态机实现的,它可以以非阻塞的方式执行异步操作,并通过回调函数来处理异步操作完成后的结果。通过链式调用和组合操作,CompletableFuture可以方便地实现复杂的异步编程任务。
基本内容的了解
Fork/Join 框架是 Java 并发编程中的一个重要工具,用于实现分治任务的并行执行。在工作中可以使用 Fork/Join 框架来解决一些需要并行计算的问题,比如大规模数据的排序、搜索和归约等任务。
Fork/Join 框架的核心是基于工作窃取(Work Stealing)算法实现的。这个算法的基本思想是让空闲的线程从其他线程的任务队列中窃取任务来执行,以达到任务的动态负载均衡。
Fork/Join 框架主要包括以下几个核心组件:
工作线程(Worker Thread): 每个工作线程都有自己的任务队列(双端队列),用于存放待执行的任务。当一个线程执行完自己任务队列中的任务后,它会尝试从其他线程的任务队列中窃取任务来执行。
任务(Task): 任务是 Fork/Join 框架中的基本执行单元。通常使用 RecursiveTask
或 RecursiveAction
类来表示任务,分别用于有返回值和无返回值的任务。任务可以递归地分解成更小的子任务,直到达到某个阈值后停止分解。
工作窃取(Work Stealing): 当一个线程的任务队列为空时,它会尝试从其他线程的任务队列中窃取任务来执行。这种机制能够充分利用多核 CPU 的计算资源,提高任务的并行度和执行效率。
线程池(ForkJoinPool): Fork/Join 框架通过 ForkJoinPool
类来管理和调度线程。线程池中的每个线程都是一个工作线程,它们会从任务队列中获取任务来执行,并且可以相互之间进行工作窃取。
总的来说,Fork/Join 框架利用工作窃取算法实现了任务的动态负载均衡,通过递归地划分任务并利用多线程并行执行任务,从而提高了并发程序的执行效率。
具体使用示例
当使用 Fork/Join 框架时,一般需要以下几个步骤:
定义任务类(RecursiveTask 或 RecursiveAction): 首先需要定义一个继承自 RecursiveTask
或 RecursiveAction
的任务类,具体取决于任务是否有返回值。在任务类中,需要实现 compute()
方法来执行实际的任务逻辑。
划分任务(拆分): 在 compute()
方法中,需要根据具体的业务逻辑来划分任务,将大任务拆分成多个小任务。这通常涉及到递归地划分任务,直到任务达到某个阈值时停止拆分。
执行任务: 使用 Fork/Join 框架的 ForkJoinPool
类来执行任务。创建一个 ForkJoinPool
实例,并调用其 invoke()
方法来执行根任务。
合并结果(如果有返回值的话): 如果任务有返回值,则需要在根任务执行完成后,合并各个子任务的结果。通常是在 compute()
方法中进行结果的合并操作。
以下是一个简单的示例,演示了如何使用 Fork/Join 框架来计算斐波那契数列的值:
- import java.util.concurrent.RecursiveTask;
- import java.util.concurrent.ForkJoinPool;
-
- class FibonacciTask extends RecursiveTask<Integer> {
- private final int n;
-
- public FibonacciTask(int n) {
- this.n = n;
- }
-
- @Override
- protected Integer compute() {
- if (n <= 1) {
- return n;
- } else {
- FibonacciTask task1 = new FibonacciTask(n - 1);
- FibonacciTask task2 = new FibonacciTask(n - 2);
- task1.fork(); // 异步执行子任务1
- return task2.compute() + task1.join(); // 同步执行子任务2,同时等待子任务1完成
- }
- }
-
- public static void main(String[] args) {
- ForkJoinPool pool = new ForkJoinPool();
- FibonacciTask task = new FibonacciTask(10);
- int result = pool.invoke(task);
- System.out.println("Result: " + result);
- }
- }
说说优势
高性能并行计算: Fork/Join 框架采用了工作窃取算法,能够有效地利用多核 CPU 的计算资源,提高任务的并行度和执行效率。通过将大任务分解成多个小任务,并在多个线程之间动态地分配和执行任务,Fork/Join 框架能够充分利用系统的计算资源,实现高性能的并行计算。
简化并发编程: Fork/Join 框架提供了高层次的抽象,使得开发者可以更轻松地编写并发程序。通过将任务的分解、执行和合并等细节封装在框架中,开发者可以专注于业务逻辑的实现,而无需关注线程的管理和同步等底层细节,从而简化了并发编程的复杂性。
任务的动态调度与管理: Fork/Join 框架提供了自动线程管理的功能,能够根据系统的负载和任务的执行情况动态地调整线程池的大小,从而避免了线程过多或过少导致的资源浪费或性能下降问题。此外,Fork/Join 框架还提供了一些监控和调优的工具,可以帮助开发者更好地管理和优化并发任务的执行。
更详细的见文章:对Java线程池ThreadPoolExecutor的理解分析
这里直接推荐使用动态线程池配置和监控更加符合业务要求,具体见上述博客!
从以下几个角度分析任务的特性:
CPU 密集型任务
、IO 密集型任务
和混合型任务
。是否依赖其他系统资源
,如数据库连接
。任务性质不同的任务可以用不同规模的线程池分开处理。 可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。
优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理,它可以让优先级高的任务先得到执行。但是,如果一直有高优先级的任务加入到阻塞队列中,那么低优先级的任务可能永远不能执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者也可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,线程数应该设置得较大,这样才能更好的利用 CPU。
建议使用有界队列,有界队列能增加系统的稳定性和预警能力。可以根据需要设大一点,比如几千。使用无界队列,线程池的队列就会越来越大,有可能会撑满内存,导致整个系统不可用。
怎么对线程池进行有效监控?
以通过线程池提供的参数读线程池进行监控,有以下属性可以使用:
通过继承线程池并重写线程池的 beforeExecute,afterExecute 和 terminated 方法,我们可以在任务执行前,执行后和线程池关闭前干一些事情。
要求 CPU 利用率达到 100% 并不意味着要将所有的 CPU 核心都使用起来,而是要确保 CPU 在处理任务时始终处于繁忙状态,尽量避免空闲。对于这个情况,计算操作需要 50ms,DB 操作需要 100ms,我们可以考虑使用以下的计算方式来确定线程数:
计算 CPU 密集型任务需要的线程数:
计算 IO 密集型任务需要的线程数:
因此,在这种情况下,可以将线程池的线程数设置为 16,以确保 CPU 在执行计算操作时能够充分利用,而在执行 DB 操作时能够保持线程不阻塞。同时,需要根据实际情况进行性能测试和调优,以确定最佳的线程数。
如果系统中不同的请求对应的 CPU 时间和 IO 时间都不同,那么可以根据系统的性能特点和负载情况,动态地调整线程池的大小来适应不同请求的处理需求。下面是一些思路:
基于负载情况动态调整:
使用弹性线程池:
根据请求类型和预估时间设置线程池参数:
监控和性能测试:
总的来说,针对不同的请求类型和预估的处理时间,可以采用动态调整线程池大小的策略来适应不同请求的处理需求,从而最大化地利用系统资源并保持良好的性能。
根据条件,线程池的核心线程数为 20,最大线程数为 600,阻塞队列大小为 200,当 QPS 达到 200 时出现请求阻塞和超时的情况。
在不能增加机器的情况下分析具体原因如下:
基于以上分析,可以采取以下措施来提高系统的吞吐量:
通过以上优化措施,可以提高系统的吞吐量,减少请求的阻塞和超时现象。
理论 QPS(Queries Per Second,每秒查询次数)是衡量系统性能的一个重要指标,它表示在一秒钟内系统能够处理的查询或请求的数量。对于单节点系统,你可以使用以下公式来计算理论 QPS:
QPS = 1 / 平均请求响应时间
其中,平均请求响应时间是指系统从接收请求到完成响应的平均时间。这个时间通常以毫秒(ms)为单位。
注意:这个公式是一个理论上的近似值,实际的 QPS 可能会受到多种因素的影响,包括系统的硬件性能、软件优化、负载、并发性等等。
假设你有一个线程池,其中包含了 N 个线程,而平均每个请求的响应时间仍然是 T 毫秒。在这种情况下,你可以使用以下公式来计算理论 QPS:
QPS = N / T
这里,N 是线程池中的线程数量,T 是平均请求响应时间(以毫秒为单位)。
线程池能够提高并发处理能力,因此你可以同时处理更多的请求,这会影响到系统的理论 QPS。但仍然要注意,线程池的性能也会受到线程数量、线程调度、任务分配等因素的影响。因此,你在使用线程池的情况下,仍然需要进行性能测试和分析,以确定系统的实际性能情况。
根据提供的信息,当前线程池大小为 200,每个线程处理一个请求的时间为 20 毫秒。可以使用以下公式来计算理论上的单节点 QPS:
QPS = 线程池大小 / 单线程处理时间
将你提供的值代入公式:
QPS = 200 / (20 ms) = 200 / 0.02 s = 10000 QPS
理论上,单节点的 QPS 可以达到 10000。
然而,这个计算是基于理论情况下的近似值。在实际应用中,系统性能可能会受到多个因素的影响,包括线程调度、并发情况、硬件性能等。因此,在实际场景中,要进行性能测试和实际负载情况下的测试,以确定系统的实际性能和 QPS。
在多线程环境下对`Long`数据进行加和会存在并发安全性问题,主要涉及以下两个方面:
解决这些问题的方法通常是使用原子操作或加锁来保证数据的正确性和一致性。Java提供了多种解决方案,其中一种常见的做法是使用`AtomicLong`类来进行原子操作:
- import java.util.concurrent.atomic.AtomicLong;
-
- public class AtomicLongExample {
- private AtomicLong sum = new AtomicLong(0L);
-
- public void addToSum(long value) {
- sum.addAndGet(value);
- }
-
-
- public long getSum() {
- return sum.get();
- }
-
- public static void main(String[] args) throws InterruptedException {
- final AtomicLongExample example = new AtomicLongExample();
- final int threadCount = 10;
- final int iterations = 100000;
-
- Runnable task = () -> {
- for (int i = 0; i < iterations; i++) {
- example.addToSum(1L);
- }
- };
-
- Thread[] threads = new Thread[threadCount];
- for (int i = 0; i < threadCount; i++) {
- threads[i] = new Thread(task);
- threads[i].start();
- }
-
- for (Thread thread : threads) {
- thread.join();
- }
-
- System.out.println("Final sum: " + example.getSum());
- }
- }
在上述示例中,使用`AtomicLong`来保证对`sum`的加和操作是原子的,从而避免了数据竞争和不正确的计算结果。
另外一种解决方案是使用锁(如`synchronized`关键字或`ReentrantLock`)来保护对`Long`数据的并发访问,确保每次只有一个线程能够对数据进行操作,从而保证数据的一致性和正确性。这样的做法虽然可以解决并发安全问题,但在高并发情况下可能会引起性能问题,因为锁会导致线程竞争和阻塞。因此,在选择解决方案时需要根据具体场景和需求来权衡利弊。
Netty是一个基于Java的异步事件驱动的网络应用框架,它的线程机制主要涉及两个方面:EventLoopGroup和EventLoop。
EventLoopGroup
EventLoop
Netty的线程模型采用了Reactor模式,其中EventLoop充当了事件处理器的角色,EventLoopGroup负责管理多个EventLoop,可以根据需要创建单线程或多线程的EventLoopGroup来适应不同的场景。这种设计使得Netty能够高效地处理并发连接和网络事件,提供了高性能的网络编程解决方案。
针对人员:
1.全部人员都适用;
2.正式员工+外包员工:看情况,但基本建议至少考察一道;
LRU算法的设计原则:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。
当存在热点数据时,LRU的效率很好,但偶发性的、周期性的批量操作会导致LRU命中率急剧下降,缓存污染情况比较严重。
实现LRU思路:
第一种方法:利用数组来实现
第二种方法:利用链表来实现
第三种方法:利用链表和hashmap来实现
对于第一种方法,需要不停地维护数据项的访问时间戳,另外,在插入数据、删除数据以及访问数据时,时间复杂度都是O(n)。对于第二种方法,链表在定位数据的时候时间复杂度为O(n)。所以在一般使用第三种方式来是实现LRU算法。
LinkedHashMap底层就是用的HashMap加双链表实现的,而且本身已经实现了按照访问顺序的存储。
此外,LinkedHashMap中本身就实现了一个方法removeEldestEntry用于判断是否需要移除最不常读取的数,方法默认是直接返回false,不会移除元素,所以需要重写该方法。即当缓存满后就移除最不常用的数。
- public class LRU<K,V> {
-
- private static final float hashLoadFactory = 0.75f;
- private LinkedHashMap<K,V> map;
- private int cacheSize;
-
- public LRU(int cacheSize) {
- this.cacheSize = cacheSize;
- int capacity = (int)Math.ceil(cacheSize / hashLoadFactory) + 1;
- map = new LinkedHashMap<K,V>(capacity, hashLoadFactory, true){
- private static final long serialVersionUID = 1;
-
- /*将LinkedHashMap中的removeEldestEntry进行重写改造*/
- @Override
- protected boolean removeEldestEntry(Map.Entry eldest) {
- return size() > LRU.this.cacheSize;
- }
- };
- }
-
- public synchronized V get(K key) {
- return map.get(key);
- }
-
- public synchronized void put(K key, V value) {
- map.put(key, value);
- }
-
- public synchronized void clear() {
- map.clear();
- }
-
- public synchronized int usedSize() {
- return map.size();
- }
-
- public void print() {
- for (Map.Entry<K, V> entry : map.entrySet()) {
- System.out.print(entry.getValue() + "--");
- }
- System.out.println();
- }
- }
基本代码见:LRU缓存机制(LRU Cache)
整体的设计思路是:可以使用 HashMap 存储 key,这样可以做到 put 和 get key的时间都是 O(1),而 HashMap 的 Value 指向双向链表实现的 LRU 的 Node 节点,如图所示。
LRU 存储是基于双向链表实现的,下面的图演示了它的原理。其中 h 代表双向链表的表头,t 代表尾部。首先预先设置 LRU 的容量,如果存储满了,可以通过 O(1) 的时间淘汰掉双向链表的尾部,每次新增和访问数据,都可以通过 O(1)的效率把新的节点增加到对头,或者把已经存在的节点移动到队头。
总结一下核心操作的步骤:
定义基本结构:
- class DLinkedNode {
- String key;
- int value;
- DLinkedNode pre;
- DLinkedNode post;
- }
具体手写代码如下:
- package org.zyf.javabasic.letcode.hash;
-
- import java.util.HashMap;
- import java.util.Map;
-
- /**
- * @author yanfengzhang
- * @description 设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作:获取数据 get 和写入数据 put 。
- * 获取数据 get(key) - 如果密钥 (key) 存在于缓存中,则获取密钥的值(总是正数),否则返回 -1。
- * 写入数据 put(key, value) - 如果密钥不存在,则写入其数据值。当缓存容量达到上限时,
- * 它应该在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。
- * <p>
- * 进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?
- * @date 2023/4/9 19:11
- */
- public class LRUCache {
- class DLinkedNode {
- int key;
- int value;
- DLinkedNode prev;
- DLinkedNode next;
- }
-
- private Map<Integer, DLinkedNode> cache = new HashMap<>();
- private int size;
- private int capacity;
- private DLinkedNode head, tail;
-
- public LRUCache(int capacity) {
- this.size = 0;
- this.capacity = capacity;
- head = new DLinkedNode();
- tail = new DLinkedNode();
- head.next = tail;
- tail.prev = head;
- }
-
- public int get(int key) {
- DLinkedNode node = cache.get(key);
- if (node == null) {
- return -1;
- }
- /*将节点移动到双向链表头部*/
- moveToHead(node);
- return node.value;
- }
-
- public void put(int key, int value) {
- DLinkedNode node = cache.get(key);
- if (node == null) {
- /*如果节点不存在,则创建一个新节点并加入到双向链表头部和哈希表中*/
- DLinkedNode newNode = new DLinkedNode();
- newNode.key = key;
- newNode.value = value;
- cache.put(key, newNode);
- addToHead(newNode);
- size++;
- if (size > capacity) {
- /*如果超出容量,则删除双向链表尾部节点并在哈希表中删除对应的键值对*/
- DLinkedNode tail = removeTail();
- cache.remove(tail.key);
- size--;
- }
- } else {
- /*如果节点存在,则更新节点的值,并将节点移动到双向链表头部*/
- node.value = value;
- moveToHead(node);
- }
- }
-
- private void addToHead(DLinkedNode node) {
- /*将节点加入到双向链表头部*/
- node.prev = head;
- node.next = head.next;
- head.next.prev = node;
- head.next = node;
- }
-
- private void removeNode(DLinkedNode node) {
- /*从双向链表中删除节点*/
- node.prev.next = node.next;
- node.next.prev = node.prev;
- }
-
- private void moveToHead(DLinkedNode node) {
- /*将节点移动到双向链表头部*/
- removeNode(node);
- addToHead(node);
- }
-
- private DLinkedNode removeTail() {
- /*删除双向链表尾部节点,并返回被删除的节点*/
- DLinkedNode tail = this.tail.prev;
- removeNode(tail);
- return tail;
- }
-
- /**
- * 可以看到,LRU 缓存机制在存储容量达到最大值时,
- * 能够正确地淘汰最近最少使用的节点,
- * 并保证每个节点的访问顺序符合 LRU 缓存机制的要求。
- */
- public static void main(String[] args) {
- LRUCache cache = new LRUCache(2);
- cache.put(1, 1);
- cache.put(2, 2);
- /*output: 1*/
- System.out.println(cache.get(1));
- cache.put(3, 3);
- /*output: -1*/
- System.out.println(cache.get(2));
- cache.put(4, 4);
- /*output: -1*/
- System.out.println(cache.get(1));
- /*output: 3*/
- System.out.println(cache.get(3));
- /*output: 4*/
- System.out.println(cache.get(4));
- }
-
- }
LRU-K中的K代表最近使用的次数,因此LRU可以认为是LRU-1。LRU-K的主要目的是为了解决LRU算法“缓存污染”的问题,其核心思想是将“最近使用过1次”的判断标准扩展为“最近使用过K次”。
相比LRU,LRU-K需要多维护一个队列,用于记录所有缓存数据被访问的历史。只有当数据的访问次数达到K次的时候,才将数据放入缓存。当需要淘汰数据时,LRU-K会淘汰第K次访问时间距当前时间最大的数据。
LRU-K具有LRU的优点,同时还能避免LRU的缺点,实际应用中LRU-2是综合最优的选择。由于LRU-K还需要记录那些被访问过、但还没有放入缓存的对象,因此内存消耗会比LRU要多。
Two queues(以下使用2Q代替)算法类似于LRU-2,不同点在于2Q将LRU-2算法中的访问历史队列(注意这不是缓存数据的)改为一个FIFO缓存队列,即:2Q算法有两个缓存队列,一个是FIFO队列,一个是LRU队列。
MQ算法根据访问频率将数据划分为多个队列,不同的队列具有不同的访问优先级,其核心思想是:优先缓存访问次数多的数据。Q0,Q1....Qk代表不同的优先级队列,Q-history代表从缓存中淘汰数据,但记录了数据的索引和引用次数的队列:
MQ需要维护多个队列,且需要维护每个数据的访问时间,复杂度比LRU高。
首先,需要对这个问题进行更加清晰的定义:
第二,理清问题的症状,这更便于定位具体的原因,有以下一些思路:
对于分布式系统,很多公司都会实现更加系统的日志、性能等监控系统。一些 Java 诊断工具也可以用于这个诊断,例如通过 JFR(Java Flight Recorder),监控应用是否大量出现了某种类型的异常。
如果有,那么异常可能就是个突破点。
如果没有,可以先检查系统级别的资源等情况,监控 CPU、内存等资源是否被其他进程大量占用,并且这种占用是否不符合系统正常运行状况。
注入式(Inject)攻击是一类非常常见的攻击方式,其基本特征是程序允许攻击者将不可信的动态内容注入到程序中,并将其执行,这就可能完全改变最初预计的执行过程,产生恶意效果。
下面是几种主要的注入式攻击途径,原则上提供动态执行能力的语言特性,都需要提防发生注入攻击的可能。
首先,就是最常见的 SQL 注入攻击。一个典型的场景就是 Web 系统的用户登录功能,根据用户输入的用户名和密码,我们需要去后端数据库核实信息。
假设应用逻辑是,后端程序利用界面输入动态生成类似下面的 SQL,然后让 JDBC 执行。
select * from use_info where username = “input_usr_name” and password = “input_pwd”
但是,如果我输入的 input_pwd 是类似下面的文本,
“ or “”=”
那么,拼接出的 SQL 字符串就变成了下面的条件,OR 的存在导致输入什么名字都是复合条件的。
select * from use_info where username = “input_usr_name” and password = “” or “” = “”
这里只是举个简单的例子,它是利用了期望输入和可能输入之间的偏差。上面例子中,期望用户输入一个数值,但实际输入的则是 SQL 语句片段。类似场景可以利用注入的不同 SQL 语句,进行各种不同目的的攻击,甚至还可以加上“;delete xxx”之类语句,如果数据库权限控制不合理,攻击效果就可能是灾难性的。
第二,操作系统命令注入。Java 语言提供了类似 Runtime.exec(…) 的 API,可以用来执行特定命令,假设我们构建了一个应用,以输入文本作为参数,执行下面的命令:
ls –la input_file_name
但是如果用户输入是 “input_file_name;rm –rf /*”,这就有可能出现问题了。当然,这只是个举例,Java 标准类库本身进行了非常多的改进,所以类似这种编程错误,未必可以真的完成攻击,但其反映的一类场景是真实存在的。
第三,XML 注入攻击。Java 核心类库提供了全面的 XML 处理、转换等各种 API,而 XML 自身是可以包含动态内容的,例如 XPATH,如果使用不当,可能导致访问恶意内容。
还有类似 LDAP 等允许动态内容的协议,都是可能利用特定命令,构造注入式攻击的,包括 XSS(Cross-site Scripting)攻击,虽然并不和 Java 直接相关,但也可能在 JSP 等动态页面中发生。
在Java程序运行阶段,可以使用以下命令行工具来查看当前Java程序的一些启动参数值,例如Heap Size等:
jps(Java进程状态工具):jps
命令用于列出当前系统中所有Java进程的进程ID和主类名。可以通过执行 jps -l
命令查看Java程序的启动参数和进程ID。
jcmd(Java控制台命令):jcmd
命令用于向正在运行的Java进程发送诊断命令,可以用来查看Java进程的启动参数、内存使用情况等信息。例如,可以执行 jcmd <pid> VM.flags
命令来查看Java进程的VM flags。
jstat(Java统计监视工具):jstat
命令用于监视Java虚拟机的各种运行时信息,包括垃圾回收情况、类加载情况、JIT编译情况等。可以使用 jstat -gc <pid>
命令来查看Java进程的垃圾回收情况。
jmap(Java内存映像工具):jmap
命令用于生成Java进程的内存映像文件,可以查看Java进程的堆内存使用情况、内存分布情况等。可以执行 jmap -heap <pid>
命令来查看Java进程的堆内存情况。
jconsole(Java监视与管理控制台):jconsole
是Java自带的图形化监视与管理控制台工具,可以实时监视Java应用程序的内存使用情况、线程情况、类加载情况等,非常方便实用。
这些命令行工具提供了丰富的功能,可以帮助开发人员深入了解Java程序的运行状态和性能特征,从而进行调优和排查问题。
可以使用 jstat
命令查看运行的Java程序的GC(垃圾回收)状况。具体的命令行格式如下:
jstat -gc <pid> <interval> <count>
其中,各个参数的含义如下:
<pid>
:Java进程的进程ID,即要监视的Java程序的进程ID。<interval>
:监视数据输出的时间间隔,单位为毫秒。表示每隔多少毫秒输出一次监视数据。<count>
:输出监视数据的次数。表示输出多少次监视数据后停止监视。例如,要查看进程ID为12345的Java程序的GC状况,每隔1秒输出一次监视数据,输出5次,可以执行以下命令:
jstat -gc 12345 1000 5
这样会输出指定Java进程的GC相关的监视数据,包括GC时间、各代的GC统计信息(如Eden区、Survivor区、Old区等)。
在Java程序运行的情况下,可以使用以下工具来跟踪某个方法的执行时间、请求参数信息等:
Java Profiler(Java性能分析器):Java性能分析器是一种用于监视和诊断Java应用程序性能问题的工具,常见的Java性能分析器包括VisualVM、YourKit Java Profiler、JProfiler等。这些工具提供了方法级别的性能分析功能,可以跟踪方法的执行时间、调用堆栈、请求参数信息等。
AspectJ(面向切面编程框架):AspectJ是一种基于Java语言的面向切面编程框架,它可以在方法执行前后插入代码逻辑,实现对方法的增强和跟踪。通过在目标方法的前后插入代码逻辑,可以实现对方法执行时间、请求参数信息等的跟踪和监视。
自定义拦截器/过滤器:在Java Web应用中,可以通过自定义拦截器或过滤器来实现对方法的执行时间、请求参数信息等的跟踪。拦截器或过滤器可以在方法执行前后记录方法的执行时间、请求参数信息等,并将这些信息记录到日志或输出到控制台。
这些工具和技术的实现原理主要包括以下几个方面:
字节码注入:Java性能分析器通常通过在Java程序的字节码中插入监视代码来实现性能监视和跟踪。AspectJ框架通过在编译期或运行期修改字节码来实现对方法的增强和跟踪。
AOP(面向切面编程):AspectJ框架采用面向切面编程的思想,通过定义切点和切面来实现对方法的跟踪和监视。切点定义了哪些方法需要被跟踪,切面定义了在方法执行前后需要执行的增强逻辑。
拦截器/过滤器链:自定义拦截器或过滤器通常通过拦截器链或过滤器链来实现对方法的跟踪和监视。在方法执行前后,拦截器或过滤器会执行相应的逻辑,记录方法的执行时间、请求参数信息等,并将这些信息输出到日志或控制台。
这些工具和技术提供了丰富的功能和灵活的扩展性,可以帮助开发人员实现对Java程序的性能监视和跟踪,从而及时发现和解决性能问题。
当一个Java程序接收请求后很长时间都没有响应,通常会采取以下步骤来排查这种问题:
确认是否出现了死锁或长时间阻塞:首先,需要确认是否出现了死锁或者某些线程长时间阻塞的情况。可以使用线程监视工具(如jstack、VisualVM等)来查看Java进程的线程堆栈信息,以确定是否有线程被阻塞或者等待锁资源。
检查日志文件:查看程序的日志文件,查找异常信息、错误日志或者警告信息,以确定是否有异常情况发生。有时候程序的运行状态会被记录在日志文件中,通过查看日志可以发现一些隐藏的问题。
检查性能指标:使用性能监视工具(如VisualVM、JProfiler、Grafana等)来监视Java程序的性能指标,包括CPU利用率、内存使用情况、线程数量、GC情况等。通过检查性能指标,可以发现程序的瓶颈和性能问题。
查看系统资源情况:检查服务器的系统资源情况,包括CPU使用率、内存使用情况、磁盘IO等。有时候程序没有响应是因为服务器资源不足或者被其他程序占用了过多的资源。
分析代码逻辑:检查程序的代码逻辑,尤其是处理请求的关键路径,查看是否有死循环、长时间阻塞、数据库连接池耗尽等问题。有时候程序没有响应是因为代码中存在性能问题或者业务逻辑错误。
通过以上步骤的排查和分析,通常可以找到程序没有响应的原因,并采取相应的措施进行解决。
NIO(New I/O)是 Java 中的一组非阻塞 I/O 类库,引入了更为灵活和高效的 I/O 操作方式。在 NIO 中,有一些重要的组件和概念,以下是一些常见的 NIO 组件:
通道(Channels): 通道是连接到文件、套接字或其他可进行 I/O 操作的实体。它们类似于传统的流,但提供了更多的功能。通道可以用于读取、写入和操作数据。
缓冲区(Buffers): 缓冲区是一个内存区域,用于在通道和应用程序之间传输数据。NIO 缓冲区提供了不同类型的缓冲区(如 ByteBuffer、CharBuffer、IntBuffer 等),以适应不同类型的数据。
选择器(Selector): 选择器允许单个线程同时监视多个通道的 I/O 事件。使用选择器,可以实现非阻塞的多路复用 I/O 操作,以管理多个连接。
选择键(SelectionKey): 选择键是通道在选择器上注册的标记。它包含了通道的事件和状态信息,允许选择器跟踪通道的状态。
非阻塞 I/O: NIO 提供了非阻塞 I/O 操作,允许在数据没有准备好的情况下继续执行其他任务,而不是阻塞等待数据的到来。这在高并发环境中非常有用。
多路复用: NIO 的选择器允许一个线程同时处理多个通道的事件,从而实现多路复用。这在服务器端应用程序中非常有用,可以处理多个客户端连接。
通道间的数据传输: NIO 提供了直接通道间的数据传输方法,可以在通道之间高效地传输数据,避免了通过缓冲区中转的开销。
这些组件一起构成了 NIO 的核心架构,使得 Java 程序能够更高效地进行非阻塞 I/O 操作,适用于需要高并发处理的网络应用程序。
Netty 是一个基于 Java NIO 的高性能网络应用框架,它在 Java NIO 的基础上做了一些优化和扩展,以提供更强大、更易用的网络编程能力。
下面是 Netty 相对于 Java NIO 做出的一些优化:
总体而言,Netty 在基于 Java NIO 的基础上进行了一系列的优化和扩展,使得开发者能够更轻松地构建高性能、可扩展的网络应用。它提供了简化的编程模型、高级抽象、内存管理优化、强大的并发模型和支持多种协议和特性,使得网络编程变得更加灵活、高效和可靠。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。