文章目录
- 2. JAVA语言
- 2.1 面向对象的三大特性
- 2.2 JAVA异常
- 2.2.1 异常产生的原因
- 2.2.2 异常的分类
- 2.2.3 异常的处理方式
- 2.3 序列化和反序列化
- 2.3.1 概念
- 2.3.2 JAVA中的序列化和反序列化
- 2.3.3 序列化和反序列化的接口
- 2.3.4 Serialization接口详解
- 2.3.5 Externalizable接口详解
- 2.3.6 Transient 关键字使用
- 2.4 String、StringBuilder、StringBuffer
- String不可变的好处
- 2.5 Sychronized关键字和Lock接口
- 2.5.1 两者的区别
- 2.5.2 线程的状态
- 2.5.3 sychronized实现原理
- 2.5.4 sychronized锁升级机制
- 2.5.5 synchronized和 ReenTrantLock的区别
- 2.5.6 synchronized 和ThreadLocal 的对比
- 2.6 JAVA多线程以及锁
- 2.6.1 JAVA线程的实现方式
- 2.6.2 Callable和Runnable接口的区别
- 2.6.3 Future对象
- 2.6.4 多线程下如何捕捉报错
- 2.6.5 CountDownLatch
- 2.6.6 CAS
- 概念
- CAS底层理解
- 示例DEMO
- CAS缺点
- 如何解决ABA问题
- 2.6.7 AQS
- 概念
- 设计思想
- 实现思路
- 2.6.8 各种锁以及应用场景
- 乐观锁和悲观锁
- 公平锁和非公平锁
- 独占锁和共享锁
- 可重入锁
- 自旋锁
- 2.6.9 ThreadLocal
- 概念
- 两大经典应用场景
- 2.6.10 线程池
- 主要参数
- 工作流程
- 2.6.11 保证线程安全的方式
- 2.6.12 线程间通信方式
2. JAVA语言
2.1 面向对象的三大特性
- 封装
- 封装是面向对象编程的核心思想,简单点说就是,我把某些东西封装起来,这些关键的核心的东西不能给你看,但是我可以提供给你一些简单使用的方法。
- 通过封装,我们可以保护代码被破坏,提高数据安全性。
- 通过封装,我们提高了代码的复用性(有些方法、类在很多地方都能多次反复使用)
- 通过封装,带来的高内聚和低耦合,使用不同对象、不同模块之间能更好的协同
- 继承
- 类和类之间有些也会具有一定的关系,比方说四边形,可以分为正方形、长方形、菱形,他们不但继承了四边形的特征,也具有属于自己的特征,这就是一种继承的关系。
- 多态
- 多态指同一个实体同时具有多种形式。同字面意思,及一个对象在不同的情况下会有不同的体现。
- 类的多态:类的多态其实就是一种继承关系;
- 方法的多态:通过重载和重写的方式实现多态。
2.2 JAVA异常
2.2.1 异常产生的原因
- (1)编写程序代码中的错误产生的异常,比如数组越界、空指针异常等,这种异常叫做未检查的异常,一般需要在类中处理这些异常
- (2)Java内部错误发生的异常,Java虚拟机产生异常
- (3)通过throw(抛出异常)语句手动生成的异常,这种异常叫做检查的异常,一般是用来给方法调用者一些必要的信息
2.2.2 异常的分类
- (1)Throwable:是异常体系的顶层类,其派生出两个重要的子类, Error 和 Exception,而 Error 和 Exception 两子类分别表示错误和异常。区别就是不检查异常(Unchecked Exception)和检查异常(Checked Exception)。
- (2)Exception 类用于用户程序可能出现的异常情况,它也是用来创建自定义异常类型类的类。
- (3)Error 定义了在通常环境下不希望被程序捕获的异常。Error 类型的异常用于 Java 运行时由系统显示与运行时系统本身有关的错误。堆栈溢出是这种错误的一例。
- 异常可能在编译时发生,也有可能在程序运行时发生,根据发生时机不同,可以分为:
- 运行时异常都是 RuntimeException 类及其子类异常,如 NullPointerException、IndexOutOfBoundsException 等,这些异常是不检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般由程序逻辑错误引起,程序应该从逻辑角度尽可能避免这类异常的发生。
- 编译时异常是指 RuntimeException 以外的异常,类型上都属于 Exception 类及其子类。从程序语法角度讲是必须进行处理的异常,如果不处理,程序就不能编译通过。如 IOException、ClassNotFoundException 等以及用户自定义的 Exception 异常,一般情况下不自定义检查异常。
2.2.3 异常的处理方式
- 防御式编程
- 错误在代码中是客观存在的. 所以要让程序出现问题的时候快速通知
- 缺点:正常流程和错误处理流程代码混在一起, 代码整体条理不清晰。
- 优点:正常流程和错误流程是分离开的, 程序员更关注正常流程,代码更清晰,容易理解代码
- 抛出异常
- 在编写程序时,如果程序中出现错误,这就需要将错误的信息通知给调用者
- 这里就可以借助关键字throw,抛出一个指定的异常对象,将错误信息告知给调用者。
- 捕获异常
- throws处在方法声明时参数列表之后,当方法中抛出编译时异常,用户不想处理该异常,此时就可以借助throws将异常抛 给方法的调用者来处理
- try-catch捕获异常并处理
- 当程序抛出异常的时候,程序员通过try-each处理了异常,如果程序抛出异常,不处理异常,那就会交给JVM处理,JVM处理就会把程序立即终止
- 并且,即使用了try-each 也必须捕获一个对应的异常,如果不是对应异常,也会让JVM进行处理
- finally回收资源
2.3 序列化和反序列化
2.3.1 概念
- 序列化(Serialization)是将对象的状态信息转化为可以存储或者传输的形式的过程,一般将一个对象存储到一个储存媒介,例如档案或记忆体缓冲等,在网络传输过程中,可以是字节或者XML等格式;而字节或者XML格式的可以还原成完全相等的对象,这个相反的过程又称为反序列化;
2.3.2 JAVA中的序列化和反序列化
- 在Java中,我们可以通过多种方式来创建对象,并且只要对象没有被回收我们都可以复用此对象。但是,我们创建出来的这些对象都存在于JVM中的堆(heap)内存中,只有JVM处于运行状态的时候,这些对象才可能存在。一旦JVM停止,这些对象也就随之消失;
- 但是在真实的应用场景中,我们需要将这些对象持久化下来,并且在需要的时候将对象重新读取出来,Java的序列化可以帮助我们实现该功能。
- 对象序列化机制(object serialization)是java语言内建的一种对象持久化方式,通过对象序列化,可以将对象的状态信息保存未字节数组,并且可以在有需要的时候将这个字节数组通过反序列化的方式转换成对象,对象的序列化可以很容易的在JVM中的活动对象和字节数组(流)之间进行转换。
- 在JAVA中,对象的序列化和反序列化被广泛的应用到RMI(远程方法调用)及网络传输中;
2.3.3 序列化和反序列化的接口
- Java为了方便开发人员将java对象序列化及反序列化提供了一套方便的API来支持,其中包括以下接口和类:
- java.io.Serializable
- java.io.Externalizable
- ObjectOutput
- ObjectInput
- ObjectOutputStream
- ObjectInputStream
2.3.4 Serialization接口详解
- Java类通过实现java.io.Serialization接口来启用序列化功能,未实现此接口的类将无法将其任何状态或者信息进行序列化或者反序列化。可序列化类的所有子类型都是可以序列化的。序列化接口没有方法或者字段,仅用于标识可序列化的语义。
- 当试图对一个对象进行序列化时,如果遇到一个没有实现java.io.Serialization接口的对象时,将抛出NotSerializationException异常。
- 如果要序列化的类有父类,要想将在父类中定义过的变量序列化下来,那么父类也应该实现java.io.Serialization接口。
- 实现接口的用例:
public class User1 implements Serializable{
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("name", name)
.append("age", age)
.toString();
}
}
- DEMO
public class SerializableDemo1 {
public static void main(String[] args) throws Exception, IOException {
//初始化对象
User1 user = new User1();
user.setName("yaomy");
user.setAge(23);
System.out.println(user);
//序列化对象到文件中
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("template"));
oos.writeObject(user);
oos.close();
//反序列化
File file = new File("template");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
User1 newUser = (User1)ois.readObject();
System.out.println(newUser.toString());
}
}
2.3.5 Externalizable接口详解
- Externalizable继承了Serializable,该接口中定义了两个抽象方法:writeExternal()与readExternal()。当使用Externalizable接口来进行序列化与反序列化的时候需要开发人员重写writeExternal()与readExternal()方法。由于上面的代码中,并没有在这两个方法中定义序列化实现细节,所以输出的内容为空。还有一点值得注意:在使用Externalizable进行序列化的时候,在读取对象时,会调用被序列化类的无参构造器去创建一个新的对象,然后再将被保存对象的字段的值分别填充到新对象中。所以,实现Externalizable接口的类必须要提供一个public的无参的构造器。
2.3.6 Transient 关键字使用
- Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。
2.4 String、StringBuilder、StringBuffer
- 因为StringBuffer对方法加了同步锁或者对调用方法加了同步锁,所以线程安全;String由于是不可变的,所以是线程安全的。
String不可变的好处
- 安全性:String是Java中最基础也是最常用的类。不可变就可以使String类型具有安全性。
- 节省空间——字符串常量池:通过使用常量池,内容相同的字符串可以使用同一个对象,从而节省内存空间。如果 String 是可变的,试想一下,当字符串常量池中的某个字符串对象被很多地方引用时,此时修改了这个对象,则所有引用的地方都会改变,这可能会导致预期之外的情况。
- 线程安全:String 对象是不可修改的,如果线程尝试修改 String 对象,会创建新的 String,所以不存在并发修改同一个对象的问题。
- 性能问题:String 被广泛应用于 HashMap、HashSet 等哈希类中,当对这些哈希类进行操作时,例如 HashMap 的 get/put,hashCode 会被频繁调用。由于不可变性,String 的 hashCode 只需要计算1次后就可以缓存起来,因此在哈希类中使用 String 对象可以提升性能。
2.5 Sychronized关键字和Lock接口
2.5.1 两者的区别
类别 | synchronized | Lock |
存在层次 | Java的关键字,在jvm层面上 | 是一个类 |
锁的释放 | 1.以获取锁的线程执行完同步代码,释放锁;2.线程执行发生异常,jvm会让线程释放锁 | 在finally中必须释放锁,否则容易造成线程死锁 |
锁的获取 | 假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待 | 分情况而定,Lock有多个锁获取的方法 |
锁状态 | 无法判断 | 可以判断 |
锁类型 | 可重入、不可中断、非公平 | 可重入、可判断、可公平可非公平 |
性能 | 少量同步 | 大量同步 |
- 两者的主要区别:
- 来源:lock是一个接口,而synchronized是java的一个关键字,synchronized是内置的语言实现;
- 异常是否释放锁:synchronized在发生异常时候会自动释放占有的锁,因此不会出现死锁;而lock发生异常时候,不会主动释放占有的锁,必须手动unlock来释放锁,可能引起死锁的发生。(所以最好将同步代码块用try catch包起来,finally中写入unlock,避免死锁的发生。)
- 是否响应中断:lock等待锁过程中可以用interrupt来中断等待,而synchronized只能等待锁的释放,不能响应中断;
- 是否知道获取锁:Lock可以通过trylock来知道有没有获取锁,而synchronized不能;
- Lock可以提高多个线程进行读操作的效率。(可以通过readwritelock实现读写分离)
- 在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。
- synchronized使用Object对象本身的wait 、notify、notifyAll调度机制,而Lock可以使用Condition进行线程之间的调度
2.5.2 线程的状态
- 新建(NEW):创建后尚未启动的线程处于这种状态。
- 运行(RUNNABLE):调用start()方法,RUNNABLE包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间(该线程已经获取了除CPU资源外的其他资源,等待获取CPU 资源后才会真正处于运行状态)。
- 无限期等待(WAITING):处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。
- 有限期等待(TIMED_WAITING):处于这种状态的线程也不会被分配CPU执行时间,不过无须等待被其他线程显式地唤醒,在一定时间之后它们会由系统自动唤醒。
- 阻塞(BLOCKED):线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程获得锁的时候可能发生,比如synchronized之外;而“等待状态”则是在获得锁之后,主动释放锁,进入等待一段时间,或者等待唤醒动作的发生。
- 结束(TERMINATED):已终止线程的线程状态,线程已经结束执行。
2.5.3 sychronized实现原理
- JVM 是通过进入、退出 对象监视器(Monitor) 来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的 互斥锁(Mutex Lock) 实现。
- 具体实现是在编译之后在同步方法调用前加入一个monitor.enter指令,在退出方法和异常处插入monitor.exit的指令。
- 对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit之后才能尝试继续获取锁。
2.5.4 sychronized锁升级机制
- 锁升级的核心过程:偏向锁-轻量级锁(自旋锁)-重量级锁
- 无锁-偏向锁
- 使用 synchronized 关键字锁住某个代码块的时候,当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
- 当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。如果从头到尾都是一个线程在使用锁,很明显偏向锁几乎没有额外开销,性能极高。
- 在偏向锁状态下如果调用了对象的 hashCode,但此时偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被 撤销,而轻量级锁会在锁记录中记录 hashCode 重量级锁会在 Monitor 中记录 hashCode,所以无影响。
- 偏向锁-轻量级锁
- 一旦有第二个线程加入锁竞争,偏向锁转换为轻量级锁(自旋锁)。锁竞争:如果多个线程轮流获取一个锁,但是每次获取的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程获取锁的时候,发现锁已经被占用,需要等待其释放,则说明发生了锁竞争。
- 在轻量级锁状态上继续锁竞争,没有抢到锁的线程进行自旋操作,即在一个循环中不停判断是否可以获取锁。获取锁的操作,就是通过 CAS 操作修改对象头里的锁标志位。先比较当前锁标志位是否为释放状态,如果是,将其设置为锁定状态,比较并设置是原子性操作,这个 是 JVM 层面保证的。当前线程就算持有了锁,然后线程将当前锁的持有者信息改为自己。
- 轻量级锁-重量级锁
- 显然,忙等是有限度的(JVM 有一个计数器记录自旋次数,默认允许循环 10 次,可以修改)。如果锁竞争情况严重, 达到某个最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是通过 CAS 修改锁标志位,但不修改持有锁的线程 ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是上面说的忙等,即不会自旋),等待释放锁的线程去唤醒。
2.5.5 synchronized和 ReenTrantLock的区别
- 两者都是可重入锁:两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
- synchronized依赖于JVM而ReenTrantLock依赖于API
- ReenTrantLock比synchronized增加了一些高级功能:相比synchronized,ReenTrantLock增加了一些高级功能。
- 主要来说主要有三点:等待可中断;可实现公平锁;可实现选择性通知(锁可以绑定多个条件)
2.5.6 synchronized 和ThreadLocal 的对比
- Synchronized关键字主要解决多线程共享数据同步问题;ThreadLocal主要解决多线程中数据因并发产生不一致问题。
- Synchronized是利用锁的机制,使变量或代码块只能被一个线程访问。而ThreadLocal为每一个线程都提供变量的副本,使得每个线程访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享
2.6 JAVA多线程以及锁
2.6.1 JAVA线程的实现方式
- 继承Thread类,重写run方法,使用start方法启动
- 实现Runnable接口,实现run方法
- 实现Callable接口,实现run方法
- 采用匿名类内部方法
2.6.2 Callable和Runnable接口的区别
- 相同点
- 1、两者都是接口;
- 2、两者都可用来编写多线程程序;
- 3、两者都需要调用Thread.start()启动线程;
- 不同点
- 1、实现Callable接口的任务线程能返回执行结果,此时需要调用FutureTask.get()方法实现,此方法会阻塞主线程直到获取call()返回的结果;当不调用此方法时,主线程不会阻塞;而实现Runnable接口的任务线程不能返回结果;
- 2、Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛;
- Callable的启动方式
1、Thread启动
public class CallableImpl implements Callable<String> {
private String acceptStr;
public CallableImpl(String acceptStr) {
this.acceptStr = acceptStr;
}
@Override
public String call() throws Exception {
// 任务等待1 秒
Thread.sleep(1000);
return this.acceptStr + ",hello!";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
//Callable<String> callable = new CallableImpl("afei");
CallableImpl impl=new CallableImpl ("afei");
FutureTask<String> task = new FutureTask<String>(impl);
long beginTime = System.currentTimeMillis();
// 创建线程
new Thread(task).start();
// 调用get()阻塞主线程,反之,线程不会阻塞
String result = task.get();//阻塞主线程,直至1s后获取到call()的返回内容
System.out.println("hello : " + result);//返回:afei,hello!
}
}
2、ExecutorService 启动
public class CallableImpl implements Callable<String> {
private String acceptStr;
public CallableImpl(String acceptStr) {
this.acceptStr = acceptStr;
}
@Override
public String call() throws Exception {
// 任务等待1 秒
Thread.sleep(1000);
return this.acceptStr + ",hello!";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool(); // 创建线程池
// 向里面扔任务并执行该任务,同时返回一个包含call()方法返回值的Featured对象
CallableImpl impl=new CallableImpl(“afei");
Future<String> future=exec.submit(impl);
//打印线程(任务)执行的结果
System.out.println(future.get());
// 关闭线程池后不接受新任务,已经在线程池的任务会被执行完
exec.shutdown();
}
}
3、controller方法中直接返回Callable对象或其派对象
@RestController
public class IAsyncController {
private Logger logger=LoggerFactory.getLogger(getClass());
@PostMapping("/async/back")
public Callable<String> back(){
logger.info("主线程开始");
Callable<String> c=new Callable<String>() {
@Override
public String call() throws Exception {
// TODO Auto-generated method stub
logger.info("副线程开始等待1s...");
Thread.sleep(1000);
logger.info("副线程结束等待");
return "afei";
}
};
logger.info("主线程结束并返回");
//启动副线程并返回call()的返回值
return c;
}
}
- Runnable启动方式
1、继承Thread类
public class Test extends Thread{
public void run(){
//操作临界资源
}
public static void main(String[] args){
Test t=new Test();
t.start();//启动线程
}
}
2、继承Runnable接口
public class Test implements Runnable{
public void run(){
//操作临界资源
}
public static void main(String[] args){
Test t=new Test();
Thread thread=new Thread(t);
thread.start();//启动线程
}
}
3、无名线程
public class Test{
public static void main(String[] args){
new Thread(()->{ //启动一个线程
System.out.pritln("启动一个线程);
}).start();
}
}
2.6.3 Future对象
- Future.get 可以获取 Callable接口返回的执行结果,还可以通过Futrue.isDone 来判断任务是否已经执行完了,以及取消这个任务,限时获取任务的结果等
- 在call()未执行完毕之前,调用get()的线程(假定此时是主线程),直到call()方法返回了结果后,此时future.get()才会得到该结果,然后主线程才会切换到runnable状态
- 所以Future是一个存储器,它存储了call()这个任务的结果,然而这个任务的执行时间是无法提前确定的,因为这完全取决于call()方法执行的情况
- Future的五个方法:
- get()方法:获取结果
- get(long timeout,Timeunit unit):超时不获取
- cancel方法:取消任务的执行
- isDone方法:判断是否执行完毕
- isCannelled方法:判断是否被取消
- FutureTask获取结果
- 把Callable实例当作参数,生成FutureTask的对象,然后把这个对象当作一个Runnable对象,用线程池或另起线程去执行这个Runnable对象,最后通过FutureTask 获取刚才执行的结果。
2.6.4 多线程下如何捕捉报错
- 多线程抛出错误之后,是不会影响主线程以及其他子线程的。
- Thread.UncaughtExceptionHandler是当线程因未捕获的异常而突然终止时调用的处理程序接口。
- 当一个线程由于未捕获的异常而即将终止时,Java虚拟机将使用它来 查询线程的 UncaughtExceptionHandler Thread.getUncaughtExceptionHandler(),并将调用该处理程序的 uncaughtException方法,并将该线程和异常作为参数传递。如果某个线程没有显式设置其UncaughtExceptionHandler,则其ThreadGroup对象将充当其 UncaughtExceptionHandler。如果ThreadGroup对象没有处理异常的特殊要求,它可以将调用转发到默认的未捕获异常处理程序。
- 示例
public static void main(String[] args) {
System.out.println("Main Thread start");
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
int i = 0;
while(i<7) {
System.out.println("Thread1 i: "+(i++));
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (i == 3) {
throw new RuntimeException();
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
// TODO Auto-generated method stub
int i = 0;
while(i<7) {
System.out.println("Thread2 i: "+(i++));
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
});
Thread.UncaughtExceptionHandler handler = new Thread.UncaughtExceptionHandler() {
public void uncaughtException(Thread th, Throwable ex) {
System.out.println("Uncaught exception: " + ex);
}
};
t2.start();
t1.setUncaughtExceptionHandler(handler);
t1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Main Thread end");
}
2.6.5 CountDownLatch
- CountDownLatch的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止。
- CountDownLatch有两个核心的方法, countDown()和await()方法.在使用CountDownLatch的时候, 必须要传入一个数值型参数作为计数器的值. 调用countDown()方法可以将值减1. 而调用了await()方法的线程,在计数器的值为0之前, 会被一直阻塞. 直到计数器的值为0时才会被唤醒. 因此.可以通过CountDownLatch来实现某个线程在其他线程执行完毕之后再执行。
- 示例
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(3);
//初始化CountDownLatch 计数器的值
CountDownLatch countDownLatch = new CountDownLatch(3);
try {
for (int i = 0; i < 3; i++) {
//创建三个线程, 每个线程执行完后调用countDownLatch 对象的countDown()方法将计数器减1
executorService.submit(new Callable<List<String>>() {
@Override
public List<String> call() throws Exception {
try {
//doSomeThing()
Thread.sleep(1000);
System.out.println(Thread.currentThread().getName() + "线程开始执行");
} catch (Exception e) {
} finally {
countDownLatch.countDown();
}
return new ArrayList<>();
}
});
}
executorService.submit(new Callable<List<String>>() {
@Override
public List<String> call() throws Exception {
try {
//doSomeThing()
//当计数器的值变为0之后,调用同一个countDownLatch对象的await()方法的主线程将会被唤醒
countDownLatch.await();
System.out.println("测试线程开始执行");
} catch (Exception e) {
} finally {
}
return new ArrayList<>();
}
});
} catch (Exception e) {
}finally {
executorService.shutdown();
}
}
2.6.6 CAS
概念
- 比较并交换 ( Compare and Swap)
- CAS(Compare-And-Swap)是一种硬件对并发的支持,针对多处理器操作而设计的处理器中的一种特殊指令,用于管理对共享数据的并发访问。
- CAS 是一种无锁的非阻塞算法的实现。
- CAS 包含了3个操作数:
- 需要读写的内存值 V
- 进行比较的值 A(预估值)
- 拟写入的新值 B(更新值)
- 当且仅当V的值等于A时,CAS算法通过原子方式用新值B来更新V的值,否则不会执行任何操作。
CAS底层理解
- Unsafe类是CAS的核心类,由于java方法无法访问底层系统,需要本地方法(native)来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存的数据,Unsafe存在于sun.misc包中,其内部的方法操作可以像C的指针一样直接操作内存,所以java中CAS操作的执行依赖于 Unsafe类的方法。
- 注意:Unsafe类中的所有方法都是native修饰的,也就是说Unsafe类中的所有方法都直接调用操作系统底层资源执行相应的任务。
示例DEMO
public class CasTest {
public static void main(String[] args) {
CompareAndSwap cas = new CompareAndSwap();
for(int i=0;i<10;i++) {
new Thread(new Runnable() {
@Override
public void run() {
int expectValue = cas.get();
boolean b = cas.compareAndSet(expectValue, (int)(Math.random() * 101));
System.out.println("本次更新是否成功:"+b);
}
}).start();
}
}
}
class CompareAndSwap {
private int value;
//获取内存值
public synchronized int get() {
return value;
}
//比较
public synchronized int compareAndSwap(int expectValue,int newValue) {
int oldValue = value;
if(oldValue == expectValue) {
this.value = newValue;
}
return oldValue;
}
//设置
public synchronized boolean compareAndSet(int expectValue,int newValue) {
return expectValue == compareAndSwap(expectValue, newValue);
}
}
CAS缺点
- 循环时间长CPU开销大:如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。
- 当对一个共享变量执行操作时,我们只能使用循环CAS的方式来保证原子操作,但是,对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。
- ABA问题:CAS算法实现一个重要前提需要取出内存中某时刻的数据并在当下时刻比较并替换,那么在这个时间差内会导致数据的变化,也就是说两个线程都读到数据为5,一个线程暂停2秒后,另一个线程把5修改为6然后又修改回5,当第一个线程来到后发现和期望值相同,则修改想要修改的值。
如何解决ABA问题
- 使用原子引用 + 新增时间戳(修改版本号)
/**
* ABA问题解决
* @author wannengqingnian
*/
public class TestAtomicStampedReference {
/**
* 创建带时间戳的原子引用
*/
static AtomicStampedReference<Integer> atomicStampedReference
= new AtomicStampedReference<>(100, 1);
public static void main(String[] args) {
//启动一个T1线程模拟ABA问题出现
new Thread(() -> {
//获取时间戳
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t第一次时间戳" + stamp);
//暂停1秒钟T1线程
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
//模拟ABA
atomicStampedReference.compareAndSet(100, 101,
atomicStampedReference.getStamp(),
atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第一次修改版本号 : "+atomicStampedReference.getStamp());
atomicStampedReference.compareAndSet(101, 100,
atomicStampedReference.getStamp(),
atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第二次修改版本号 : "+atomicStampedReference.getStamp());
}, "T1").start();
//启动T2线程验证是否解决ABA问题
new Thread(() -> {
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t线程获得的版本号 :"+stamp);
//暂停3秒,确保T1完成ABA问题
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//开始修改
Boolean flag = atomicStampedReference.compareAndSet(100, 2019, stamp, stamp + 1);
System.out.println(Thread.currentThread().getName() + "\t修改是否成功"+flag + "\t此时的版本号" + atomicStampedReference.getStamp());
}, "T2").start();
}
}
2.6.7 AQS
概念
- Abstract Queued Synchronizer。是并发容器J.U.C(java.util.concurrent)下locks包内的一个类。它实现了一个FIFO(FirstIn、FisrtOut先进先出)的队列。底层实现的数据结构是一个双向链表。
- AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
- AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
- 状态信息通过protected类型的getState,setState,compareAndSetState进行操作
设计思想
- 使用Node实现FIFO队列,可以用于构建锁或者其他同步装置的基础框架。
- 基于AQS有一个同步组件,叫做ReentrantLock。在这个组件里,stste表示获取锁的线程数,假如state=0,表示还没有线程获取锁,1表示有线程获取了锁。大于1表示重入锁的数量。
- 继承:子类通过继承并通过实现它的方法管理其状态(acquire和release方法操纵状态)。
- 可以同时实现排它锁和共享锁模式(独占、共享),站在一个使用者的角度,AQS的功能主要分为两类:独占和共享。它的所有子类中,要么实现并使用了它的独占功能的api,要么使用了共享锁的功能,而不会同时使用两套api,即便是最有名的子类ReentrantReadWriteLock也是通过两个内部类读锁和写锁分别实现了两套api来实现的。
实现思路
- AQS内部维护了一个CLH队列来管理锁。线程会首先尝试获取锁,如果失败就将当前线程及等待状态等信息包装成一个node节点加入到同步队列sync queue里。 接着会不断的循环尝试获取锁,条件是当前节点为head的直接后继才会尝试。如果失败就会阻塞自己直到自己被唤醒。而当持有锁的线程释放锁的时候,会唤醒队列中的后继线程。
- CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配。
2.6.8 各种锁以及应用场景
- 总共有八种锁:乐观锁悲观锁、公平锁非公平锁、独占锁共享锁、可重入锁、自旋锁
乐观锁和悲观锁
- (1)乐观锁和悲观锁其实是在数据库中引入的名词,但在Java并发编程中也体现了这样的思想。
- 悲观锁指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加排它锁,并在整个数据处理过程中,使数据处于锁定状态。
- 乐观锁相对悲观锁来说的,认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而在进行数据提交更新时,才会正式对数据冲突与否进行检测 。
- (2)实现方式
- 在数据库中,悲观锁的实现往往靠数据库提供的锁机制,在对数据记录操作前给记录加排它锁;乐观锁的实现则不会使用数据库的锁机制,一般在表中添加version字段来做类似CAS的自旋操作。
- 在Java中,synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现;java.util.concurrent.atomic 等原子变量类就是基于CAS机制乐观锁思想的实现。
- (3)使用场景
- 乐观锁适用于多读少写的场景,即线程间的冲突发生较少的时候。
- 悲观锁适用于少读多写的场景,即线程间的冲突发生较多的时候。
公平锁和非公平锁
- (1)根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁。
- 公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,有个先来后到的原则,最早请求锁的线程会先获得锁。
- 非公平锁表示线程获取锁的顺序与线程请求锁的时间早晚无关,先来不一定先获得锁。
- (2)实现方式
- ReentrantLock 提供了公平锁和非公平锁的实现
- 非公平锁: ReentrantLock nonfairLock = new ReentrantLock(); ReentrantLock 的无参构造方法是非公平锁,这与有参构造方法传入false的效果一样。
- 公平锁:ReentrantLock fairLock = new ReentrantLock(true); 有参构造方法传入true。
- (3)使用场景
- 在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销。
- 如果业务中线程处理时间要远长于线程等待,那用非公平锁其实效率并不明显,但是用公平锁会给业务增强很多的可控制性。
独占锁和共享锁
- (1)根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。
- 独占锁保证任何时候都只有一个线程能得到锁。
- 共享锁则可以同时由多个线程持有。
- (2)实现方式
- ReentrantLock是以独占方式实现的。
- ReadWriteLock读写锁,它允许一个资源可以被多个线程同时进行读操作。
- (3)使用场景
- 独占锁适用于多写的场景。因为读操作并不会影响数据的一致性 ,而独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
- 共享锁适用于多读的场景。共享锁是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。
可重入锁
- 当一个线程要获取一个被其他线程持有的独占锁时,该线程会被阻塞;但当一个线程再次获取它自己已经获取的锁时,如果不被阻塞,那么这个锁就是可重入锁。
- synchronized内置锁是可重入锁。可重入锁的原理是在锁内部维护一个线程标识,用来标志该锁目前被哪个线程占用,然后关联一个计数器。一开始计数器值为0,说明该锁未被任何线程占用。当一个线程获取了该锁时,计数器值+1,这时其他线程再来获取该锁时会发现锁的所有者不是自己而被挂起;但当获取了该锁的线程再次获取锁时发现锁拥有者是自己,就会把计数器值+1,释放锁把计数器值-1;当计数器值为0时,锁里面的线程标识被置为null,这时被阻塞的其他线程就会被唤醒来竞争该锁。
自旋锁
- 由于 Java 中的线程是与操作系统中的线程一一对应的,所以当一个线程在获取锁(比如独占锁)失败后,会被切换到内核状态而被挂起,当该线程获取到锁时又需要将其切换到内核状态而唤醒该线程。而从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发性能。
- 自旋锁则是,当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认次数是10 ,可以使用-XX:PreBlockSpinsh参数设置该值),很有可能在后面几次尝试中其他线程己经释放了锁,如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用CPU时间换取线程阻塞与调度的开销,但是很有可能这些CPU时间白白浪费。
- 实现方式
- CAS机制是自旋锁的主要实现方式。
2.6.9 ThreadLocal
概念
- ThreadLocal叫做线程变量,意思是ThreadLocal中填充的变量属于当前线程,该变量对其他线程而言是隔离的,也就是说该变量是当前线程独有的变量。ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。
- ThreadLoal 变量,线程局部变量,同一个 ThreadLocal 所包含的对象,在不同的 Thread 中有不同的副本。这里有几点需要注意:
- 因为每个 Thread 内有自己的实例副本,且该副本只能由当前 Thread 使用。这是也是 ThreadLocal 命名的由来。
- 既然每个 Thread 有自己的实例副本,且其它 Thread 不可访问,那就不存在多线程间共享的问题。
- ThreadLocal 提供了线程本地的实例。它与普通变量的区别在于,每个使用该变量的线程都会初始化一个完全独立的实例副本。ThreadLocal 变量通常被private static修饰。当一个线程结束时,它所使用的所有 ThreadLocal 相对的实例副本都可被回收。
- 总的来说,ThreadLocal 适用于每个线程需要自己独立的实例且该实例需要在多个方法中被使用,也即变量在线程间隔离而在方法或类间共享的场景。
两大经典应用场景
- 典型场景1:每个线程需要一个独享的对象(通常是工具类,典型需要使用的类有 SimpleDate Format和 Random)
- 典型场景2:每个线程内需要保存全局变量(例如在拦截器中获取用户信息),可以让不同方法直接使用,避免参数传递的麻烦。例如用 Threadlocal保存一些业务内容(用户权限信息、从用户系统取到的用户名、 user id等),这些信息在同一个线程內相同,但是不同的线程使用的业务内容是不相同的。
2.6.10 线程池
主要参数
- 线程池核心线程大小 corePoolSize:线程池中维护的一个最少的线程数量,即使这些线程处于空闲状态,他们也不会被销毁,除非设置了allowCoreThreadTimeOut。
- 线程池最大线程数量 maximumPoolSize:一个任务被提交到线程池之后,首先会到工作队列中,如果工作队列满了,则会创建一个新的线程,然后从工作队列中取出一个任务交给新线程处理,而将刚提交上来的任务放入到工作队列中。线程池最大的线程数量由maximunPoolSize来指定。
- 空闲线程存活时间 keepAliveTime:一个线程如果处于空闲状态,并且当前的线程数量大于corePoolSize,那么在指定的时间后,这个空闲的线程将被销毁,这个指定的时间就是keepAliveTime。
- 空闲线程存活时间单位 unit:keepAliveTime的计量单位,是一个枚举java.util.concurrent.TimeUnit。
- 工作队列 workQueue:新任务被提交之后,会先进入到此工作队列中,任务调度时再从队列中取出任务。jdk一共提供了四种工作队列。
- ArrayBlockingQueue 数组型阻塞队列:数组结构,初始化时传入大小,有界,FIFO(先进先出),使用一个重入锁,默认使用非公平锁,入队和出队共用一个锁,互斥。
- LinkedBlockingQueue 链表型阻塞队列:链表结构,默认初始化大小为Integer.MAX_VALUE,有界(近似无解),FIFO,使用两个重入锁分别控制元素的入队和出队,用Condition进行线程间的唤醒和等待。
- SynchronousQueue 同步队列:容量为0,添加任务必须等待取出任务,这个队列相当于通道,不存储元素。
- PriorityBlockingQueue 优先阻塞队列:无界,默认采用元素自然顺序升序排列。
- DelayQueue 延时队列:无界,元素有过期时间,过期的元素才能被取出。
- 线程工厂 threadFactory:创建新线程的时候使用的工厂,可以用来指定线程名,是否为daemon线程等等。
- 拒绝策略 handler:当工作队列中的任务已经达到了最大的限制,并且线程池中线程数量达到了最大限制,如果这时候有新任务进来,就会采取拒绝策略,jdk中提供了四种拒绝策略。
- AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
- DiscardPolicy:丢弃任务,但是不抛出异常。可能导致无法发现系统的异常状态。
- DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。
- CallerRunsPolicy:由调用线程处理该任务。
工作流程
- 1.线程池判断核心线程池里的核心线程是否都在执行任务。 如果不是,让空闲的核心线程来执行任务。如果核心线程池里的线程都在执行任务,则进入下个流程。
- 2.线程池判断阻塞队列是否已满。 如果阻塞队列没有满,则将新提交的任务存储在阻塞队列中。如果阻塞队列已满,则进入下个流程。
- 3.线程池判断线程池里的线程数量是否小于最大线程数量(看线程池是否满了)。 如果小于,则创建一个新的工作线程(非核心线程,并给它设置超时时间,当我们处理完这些任务,无需手动销毁这个非核心线程,超时自动销毁)来执行任务。如果已满,则交给拒绝策略来处理这个任务。
2.6.11 保证线程安全的方式
- 互斥同步
- 互斥同步是最常见的一种并发正确性保障手段。同步是指在多线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用(同一时刻,只有一个线程在操作共享数据)。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里面,互斥是因,同步是果;互斥是方法,同步是目的。
- 在java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码质量,这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
- 此外,ReentrantLock也是通过互斥来实现同步。在基本用法上,ReentrantLock与synchronized很相似,他们都具备一样的线程重入特性。
- 互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确地同步措施(例如加锁),那就肯定会出现问题,无论共享数据是否真的会出现竞争,它都要进行加锁。
- 非阻塞同步
- 随着硬件指令集的发展,出现了基于冲突检测的乐观并发策略,通俗地说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再采用其他的补偿措施。(最常见的补偿错误就是不断地重试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。
- 非阻塞的实现CAS(compareandswap):CAS指令需要有3个操作数,分别是内存地址(在java中理解为变量的内存地址,用V表示)、旧的预期值(用A表示)和新值(用B表示)。CAS指令执行时,CAS指令指令时,当且仅当V处的值符合旧预期值A时,处理器用B更新V处的值,否则它就不执行更新,但是无论是否更新了V处的值,都会返回V的旧值,上述的处理过程是一个原子操作。
- 无需同步方案
- 要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,那它自然就无需任何同步操作去保证正确性,因此会有一些代码天生就是线程安全的。
- 1)可重入代码
- 可重入代码(ReentrantCode)也称为纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。所有的可重入代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。
- 可重入代码的特点是不依赖存储在堆上的数据和公用的系统资源、用到的状态量都是由参数中传入、不调用 非可重入的方法等。
- (类比:synchronized拥有锁重入的功能,也就是在使用synchronized时,当一个线程得到一个对象锁后,再次请求此对象锁时时可以再次得到该对象的锁)
- 2)线程本地存储
- 如果一段代码中所需的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内。这样无需同步也能保证线程之间不出现数据的争用问题。
- 符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典的Web交互模型中的“一个请求对应一个服务器线程(Thread-per-Request)”的处理方式,这种处理方式的广泛应用使得很多Web服务器应用都可以使用线程本地存储来解决线程安全问题。
2.6.12 线程间通信方式
- 等待-通知机制
- 一个线程修改了一个对象的值,二另一个线程感知到了变化,然后进行相应的操作,整个开始与一个线程,而最终执行又是另一个线程。等待—通知机制使用的是使用同一个对象锁,如果两个线程使用的是不同的对象锁,那它们之间是不能用等待—通知机制的通信的。
- 内存共享
- 同步sychronized关键词
- 信号量volatile:java支持多个线程同时访问一个对象或者对象的成员变量,由于每个线程可以拥有这个变量的拷贝,所以程序在执行过程中,一个线程看到的变量并不一定是最新的。关键字volatile可以用来修饰字段(成员变量),就是告知程序对该变量的访问均需要从共享内存中获取,而对它的改变必须同步刷新回共享内存,它能保证所有线程对变量访问的可见性。
- 循环队列:生产者消费者模型。是通过一块缓冲区作为容器,来解决生产者和消费者之间的强耦合关系。通俗来讲就是,在该模型之前,只有当前顾客来了,店家才会生产商品,这样的话,来多个顾客就会将大量时间浪费在排队上,白白浪费时间。而有了生产者消费者模式之后,店家在没有顾客的时候,也生产商品,并将商品放在一个容器中,顾客来了直接拿即可,只有当容器满了便停止生产。而生活中大多都采用这个模型。