目录
深入理解synchronized关键字&重量级锁&轻量级锁&偏向锁
1.非静态方法 --> 锁对象是this(调用此方法的对象)
无锁的 CAS 效率比 synchronized 的效率更高
AQS中多个线程抢资源是通过CAS设置state状态,保证操作的原子性。
AQS既是公平锁也是非公平锁(AQS既可以实现公平锁也可以实现非公平锁)
4.提供了"延迟"和"周期执行"功能的ThreadPoolExecutor
线程与进程
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。在指令运行过程中还需要用到磁盘,网络等设备。进程就是用来加载指令,管理内存,管理IO的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
串行&并发&并行
并发: 并发是指系统能够处理多个任务的能力,这些任务在同一时间段内开始执行,但并不一定要在同一时间段内结束。在并发执行中,这些任务通常是部分重叠的。在单核处理器系统中,通过任务切换(也称为上下文切换),系统可以在多个任务之间快速切换,给人一种同时处理多个任务的错觉。在多核处理器系统中,虽然可以做到真正的并行处理,但并发性也涉及到如何有效地管理这些并行的任务。
并行: 并行是指多个任务或多个部分同时在多个处理器上执行。并行性是一种通过增加资源来提高性能的方法,即通过使用多个处理器、多个核心或多个节点来同时执行多个任务。并行处理需要硬件支持,例如多核CPU或多台计算机。
串行: 串行执行是指任务一个接一个地按顺序执行,一个任务的开始必须在另一个任务结束后才开始。串行执行没有并发性或并行性,它是最简单的执行模型,但通常效率较低,尤其是在处理大量任务时。
关系:
- 串行是并发和并行的基础。在无法并发或并行处理的情况下,任务将按串行方式执行。
- 并发是串行的扩展,它允许多个任务在单个核心的上下文切换中“同时”执行。
- 并行是并发的进一步扩展,它通过在多个处理器上同时执行多个任务来提高性能。
多线程的多种实现方式
- 继承Thread类:
优势:
1.直接继承Thread类,简单易用。
2.可以直接调用Thread类的方法,如sleep()、join()等。
劣势:
1.Java不支持多重继承,因此继承Thread类后无法再继承其他类。
2.不适合用于资源共享,因为继承的方式耦合度较高。
- 实现Runnable接口:
-
- 优势:
-
-
- 避免了由于Java单继承特性带来的限制。
- 更适合资源共享,因为实现了Runnable接口的类可以与任意其他类一起使用。
- 代码更加模块化,易于维护。
-
-
- 劣势:
-
-
- 需要创建Thread对象来运行Runnable实例,稍微多一些步骤。
-
- 实现Callable接口:
-
- 优势:
-
-
- 可以通过FutureTask获取线程的执行结果。(实现Callable接口时,重写的call()方法含返回值)
- 可以抛出异常,使得线程中的异常可以被捕获和处理。
-
-
- 劣势:
-
-
- 使用稍微复杂,需要与Future和ExecutorService配合使用。
-
- 使用ExecutorService:
-
- 优势:
-
-
- 线程池可以有效地管理和重用线程,减少线程创建和销毁的开销。
- 提供了任务队列,可以控制任务的执行策略。
- 支持定时执行、并发数控制等功能。
-
-
- 劣势:
-
-
- 相对于直接创建线程,使用线程池需要更多的设置和管理工作。
-
- 使用ForkJoinPool:
-
- 优势:
-
-
- 适用于需要大量计算的任务,尤其是可以递归分解的任务。
- 可以利用多处理器的优势,提高计算效率。
-
-
- 劣势:
-
-
- 对于简单的并行任务,可能不如ExecutorService简单直接。
- 需要理解任务分解和合并的机制,编写代码较为复杂。
-
- 使用ParallelStream(Java 8及以上):
-
- 优势:
-
-
- 简化了并行计算的编程模型,通过流的方式处理数据。
- 内部使用ForkJoinPool,可以利用多处理器的计算能力。
-
-
- 劣势:
-
-
- 对于复杂的并行任务,可能不如直接使用ForkJoinPool灵活。
- 性能调优和错误处理相对困难。
-
之间的关系和比较
- Thread类和Runnable接口是最基本的两种实现多线程的方式,它们提供了最基础的线程功能。
- Callable接口是Runnable的增强版,可以返回结果或抛出异常。
- ExecutorService是线程池的实现,可以管理多个线程的生命周期,提高效率。
- ForkJoinPool是用于执行ForkJoinTask的线程池,特别适合于递归任务分解。
- ParallelStream是Java 8引入的并行流,简化了并行计算的编程,内部使用ForkJoinPool。
在选择多线程实现方式时,应根据具体的应用场景和需求来决定。对于简单的并行任务,ParallelStream可能是一个很好的选择。对于需要精细控制的并行任务,ForkJoinPool可能更合适。而ExecutorService则适用于需要管理和调度多个线程的情况。对于简单的独立任务,Runnable和Callable接口通常是最佳选择。
线程的六大状态
1.线程包括哪些状态
新建(new),可运行(runnable),阻塞(blocked),等待(waiting),
时间等待(timed_waiting),终止(terminated)
2.线程状态之间是如何变化的
创建线程对象是新建状态
调用了start()方法转变为可执行状态
线程获取到了cpu的执行权,执行结束是终止状态
在可执行状态的过程中,如果没有获取cpu的执行权,可能会切换其他状态
- 如果没有获取锁 (synchronized或lock)进入阻塞状态,获得锁再切换为
可执行状态 - 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可
切换为可执行状态 - 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为
可执行状态
如何保证三个线程按顺序执行
插入线程。可以使用线程中的join方法解决,join(): 等待线程运行结束。
例1:
Thread t1 = new Thread(()-> {
System.out.println("t1");
});//这里创建线程对象用的是 实现Runnable()方法,且用了Lambda表达式这种函数式编程表示。
//写成 匿名内部类 的形式如下:
/*Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1");
}
});*/
Thread t2 = new Thread(()-> {
try{
t1.join(); //加入线程1,只有1线程执行完毕以后,再次执行该线程
} catch(InterruptedException e){
e.printStackTrace();
}
System.out.println("t2");
});
Thread t3 = new Thread(()->{
try{
t2.join(); //加入线程2,只有t2线程执行完毕以后,再次执行该线程
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println("t3");
});
//启动线程
t3.start();
t2.start();
t1.start();
//最后启动线程时不管代码的顺序是怎么样的,线程的执行顺序一定是t1,t2,t3
例2:
public class MyThread extends Thread{
@Override
public void run() {
for (int i = 1; i <= 100; i++) {
System.out.println(getName() + "@" + i);
}
}
}
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
//插入线程/插队线程
MyThread t = new MyThread();
t.setName("土豆");
t.start();
//表示把t这个线程,插入到当前线程之前
// t:土豆 当前线程:main线程
t.join();
//执行在main线程当中的
for (int i = 0; i < 10; i++) {
System.out.println("main线程" + i);
}
}
}
notify()和notifAll()有什么区别
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个wait线程
JAVA中的wait()和sleep()方法有什么区别
共同点
wait(),wait(long)和sleep(long)的效果都是让当前线程暂时放弃CPU的使用权,进入阻塞状态
不同点
1.方法归属不同
sleep(long)是Thread的静态方法
而wait(),wait(long)都是Object的成员方法,每个对象都有
2.醒来时机不同
1.执行 sleep(long)和wait(long)的线程都会在等待相应毫秒后醒来
2.wait(long)和wait()还可以被notify 唤醒,wait()如果不唤醒就一直等下去
3.它们都可以被打断唤醒
3.锁特性不同(重点)
1.wait方法的调用必须先获取wait对象的锁,而sleep则无此限制。 wait 方法的调用必须先获取调用 wait 方法的对象的锁,这意味着必须在synchronized 块或方法中调用 wait 方法。这是因为 wait 方法是 Object 类的一部分,它是用来在多线程环境下实现线程间通信的一种机制。当一个线程调用一个对象的 wait 方法时,该线程会释放该对象的锁,并进入等待状态,直到另一个线程在同一对象上调用 notify 或 notifyAll 方法将其唤醒。
例:
synchronized (object) {
object.wait(); //释放对象锁并wait
}
2.wait方法执行后会释放对象锁,分许其它线程获得该对象锁(我放弃CPU,但你们还可以用)
3.而sleep如果在synchronized代码块中执行,并不会释放对象锁(我放弃CPU,你们也用不了)
Thread.sleep(long millis) 方法是 Thread 类的一部分,它会使当前线程暂停执行指定的毫秒数。调用 sleep 方法并不会释放当前线程持有的任何锁,也不会影响其他线程的执行。因此,sleep 方法可以在没有锁的情况下调用,不需要与 synchronized 结合使用。但是,如果在 synchronized 块或方法中调用 sleep,线程在睡眠期间仍然会保持锁,其他线程将无法进入该同步块或方法。
如何停止一个正在运行的线程
1、使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
2、使用stop方法强行终止(不推荐,方法已作废)
3、使用interrupt方法中断线程
- 打断阻塞的线程(sleep、wait、join)的线程,线程会抛出InterruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
深入理解synchronized关键字&重量级锁&轻量级锁&偏向锁
介绍
synchronized [对象锁] 采用互斥的方式让同一时刻至多只有一个线程能持有 [对象锁],其它线程再想获取这个 [对象锁] 时就会阻塞住。
锁对象必须唯一!!!
同步代码块
1.加个static可以保证锁对象的唯一性。
例:
static Object obj = new Object();
2.用当前类的字节码文件对象做锁对象(保证了锁对象的唯一性)
同步方法(可以去看狂神说介绍的"八锁现象")
同步方法的 锁对象不能自己指定。
1.非静态方法 --> 锁对象是this(调用此方法的对象)
作用范围仅仅是同一个对象,
1、不同的对象去调用 "相同的同步方法"或者"不同的同步方法",他们之间都不会阻塞。
2、而相同的对象去调用不同的同步方法,会阻塞(在线程 1 使用该对象去调用同步方法 1 时,该对象作为锁对象被线程 1 获取,而此时线程 2 也使用该对象来调用另一个同步方法时,这个同步方法的锁对象也是该对象,但是此时锁对象被线程 1 拿着,线程 2 将会阻塞,直到线程 1 执行完同步方法 1,释放锁对象,线程 2 才有机会获取到锁对象)
使用非静态同步方法的重点是正确地找到调用此方法的对象是哪一个对象,根据这个对象去判断不同的线程来会不会阻塞。
2.静态方法 --> 锁对象是当前类的字节码文件对象
作用范围是全局的,也就是类下所有的对象。(这个类就是当前类)
1、同一个类的不同的实例对象去调用该同步方法(因为这个同步方法时静态的,所以一般还是用类去调用),他们之间会阻塞。
2、同一个类的不同的实例对象去调用不同的同步方法,他们之间也会阻塞,它们也会形成竞争。
synchronized的特性
- 可重入锁: 同一个线程可以重复获取同一把锁,因为Java的锁是绑定到线程的,而不是代码块的。这可以避免死锁。
- 非公平锁: 当锁被释放后,任何等待的线程都有机会获取该锁,而不是按照它们等待的先后顺序。这可能导致某些线程长时间等待,但通常情况下,这可以提供比公平锁更好的吞吐量。 公平锁的弊端:如果线程1执行需要3s,线程2执行需要3H,可能会造成线程1排在线程2后面去执行,非常不智能。
- 不可中断锁: 当线程正在执行同步代码块时,如果另一个线程试图中断它,那么线程的锁是不会被释放的,即线程会继续执行完同步代码块。
synchronized的底层原理
1.基于Monitor监视器
synchronized的同步机制是基于Java对象的监视器(Monitor)实现的。每个Java对象都可以作为监视器,当线程获取对象的锁时,它就进入了对象的监视器。线程执行完同步代码块或方法后,它会释放对象的锁,从而退出对象的监视器。 线程获得锁需要使用 锁对象 关联monitor。
monitor 内部的三个属性:
Owner:存储当前获取锁的线程的,只能有一个线程可以获取。
EntryList:关联没有抢到锁的线程,处于Blocked状态的线程。
WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程。
2.重量级锁
对象的内存结构:
在HotSpot虚拟机(JVM的一种实现)中,对象在内存中存储的布局可分为3块区域;对象头,实例数据,对齐填充。
MarkWord:
锁对象怎么关联上的Monitor:
3.轻量级锁
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
轻量级锁的主要优点是在没有锁竞争或者竞争不激烈的情况下,可以减少线程上下文切换的开销,提高程序的并发性能。轻量级锁适用于锁被短暂持有的场景,因为在这种情况下,使用轻量级锁可以避免使用重量级锁带来的性能开销。
例:
底层原理:
1.加锁
Object:锁对象 mark word(重点):无锁状态下存着hashcode,age,lock标志(01)等数据
klass word:对象类型 Object body:对象实例数据
每个线程的栈帧都会包含一个锁记录结构。 Lock Record:锁记录
--------->
---使用CAS交换信息后-->
此时,代表该线程拥有了该轻量锁。
有以下两种特殊情况:
1.发生锁重入了。即当前线程已经持有该锁了,代表这是一次锁重入。会在栈帧中添加一个锁记录Lock Record,作为重入的计数(这个线程重入了这个锁几次)。
第二次虽然也会进行CAS操作,但是数据不会再次交换了。
2.CAS失败了
Object对象处于有锁状态,发生竞争,轻量级锁会立刻膨胀为重量级锁。
(注:CAS操作:保证在修改数据的时候是一个原子操作。)
2.解锁
1.如果Lock Record为null,减去这个锁机录。(减去多余的锁记录Lock Record)
2.如果Lock Record不为null,利用CAS操作把值交换回来。
总结
加锁流程
1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
2,通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
3,如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
4.如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁过程
1.遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
2.如果Lock Record为null,代表这是一次重入,将obj设置为null后continue。
3,如果Lock Record不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态,如果失败则膨胀为重量级锁。
需要注意的是,轻量级锁的使用是由JVM在运行时根据锁的状态和竞争情况自动选择的。我们无法在代码中强制JVM使用轻量级锁,只能编写符合同步规范的代码,然后由JVM在运行时根据实际情况进行优化。
4.偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。
java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程id设置到对象的Mark Word头,之后发现这个线程id是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。
与轻量级锁的不同之处:
1.只进行一次CAS操作。 2.CAS操作时不进行数据交换,而是将线程id存入Mark Word,并把Mark Word的biased_lock字段改成1。
三大锁总结
在Java虚拟机(JVM)层面,synchronized的锁优化和锁升级机制也是非常重要的。JVM会根据锁的竞争情况,将锁从偏向锁升级到轻量级锁,再升级到重量级锁。这样可以提高锁的性能,减少线程上下文切换的开销。
总之,synchronized是Java提供的一种内建的同步机制,它可以保证在同一时刻只有一个线程可以执行某个方法或代码块,从而解决多线程并发访问共享资源的问题。虽然synchronized使用简单,但它也有一些局限性,比如无法中断正在等待获取锁的线程,以及锁的粒度较大可能导致性能问题。因此,在某些复杂的并发场景下,可以考虑使用Java提供的java.util.concurrent.locks.Lock接口及其实现类。
Java内存模型(JMM)
定义与内容
JMM 定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。
JMM 指 Java 内存模型(Java Memory Model),它是 Java 虚拟机(JVM)规范中定义的一种抽象模型,用于规范多线程环境下共享变量的访问规则,解决并发编程中的可见性、有序性和原子性问题。JMM 是 Java 并发编程的核心基础,开发者必须理解其规则才能编写正确、高效的多线程程序。
内存分为工作内存和主内存。
线程只能访问自己的工作内存,不能访问其他线程的工作内存。也就说明了 工作内存中的数据不存在线程安全的问题。
主内存中的数据是共享数据。
工作内存可以把数据同步到主内存,主内存也可以把数据同步到工作内存。
多个线程同步数据就是依据这个。
总结
CAS
概念
CAS的全称是:Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
CAS数据交换流程
一个当前内存值 V,旧的预期值 A,即将更新的值 B,当且仅当旧的预期值 A 和内存值 V 相同时,将内存值修改为 B 并返回true,否则什么都不做,并返回false。如果 CAS 操作失败,通过自旋的方式等待并再次尝试,直到成功。
--------->
--------->
自旋:
再次从主内存同步数据过来,然后a--,再把数据同步过去。
自旋锁并不是锁。
特点
1.因为没有加锁,所以线程不会陷入阻塞,效率较高。
2.如果竞争激烈,重试频繁发生,效率会受影响。
底层实现
CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令。
Unsafe 类里面的相关方法:
native 本地方法:系统提供的,c 或者 c++语言实现的方法。
总结
CAS 原理、原子整数、原子引用
CAS 能在无锁情况下保证线程操作共享数据的原子性,也就是 CAS操作能保证在修改数据的时候是一个原子操作。
原理:
原子整数类型
JUC 并发包提供了以下 API:(原子整数)
- AtomicBoolean
- AtomicInteger
- AtomicLong
以上 API 对相应数据类型(Boolean、Integer、Long)的操作进行封装,从而使这些操作成为原子操作。
以上原子整数 API 的底层是通过 CAS 来实现的:
举例:
原子引用类型(提供保护引用类型数据的操作)
- AtomicReference
- AtomicMarkableReference
- AtomicStampedReference
用法和原子整数十分类似!!!
ABA 问题
线程1要把1改成2,结果线程2执行快把1改成3,又把3改成1,正常这个值被动过,线程1应该不能修改成功,但由于比较值仍然是1没有变化,所以线程1仍然能修改成功,这就是ABA问题。
代码实现:
问题解决:
使用 AtomicStampedReference 原子工具
原理:加一个版本号比对。
AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如:A --> B --> A --> C,通过 AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。
AtomicMarkableReference:(比对的不再是一个整数类型的递增变化的版本号,而是一个 Boolean 类型的数据,只记录数据是否被修改过)
CAS 与 volatile
无锁的 CAS 效率比 synchronized 的效率更高
所以在线程数小于 CPU 核心数的情况下使用 CAS 是最合适的。
乐观锁与悲观锁
volatile
功能
一旦一个共享变量(类的成员变量,类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
1.保证线程间的可见性
2.禁止进行指令重排序
保证变量的可见性和有序性,不保证原子性。使用了 volatile 修饰变量后,在变量修改后会立即同步到主存中,每次用这个变量前会从主存刷新。
因为volatile不能实现原子性,所以其不能保证线程安全。
1.保证线程间的可见性
原理(读写屏障保证可见性):
用volatile修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。
例子:
2.禁止指令进行重排序
用volatile修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果。
例:
volatile使用技巧:
写变量让volatile修饰的变量的在代码最后位置
读变量让volatile修饰的变量的在代码最开始位置
AQS
概念
全称是AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架。
AQS与synchronized:
AQS常见的实现类
ReentrantLock 阻塞式锁
Semaphore 信号量
CountDownLatch 倒计时锁
AQS基本工作机制
底层维护了一个state状态,volatile修饰(对多个线程保证可见性)。先是线程0拿到锁,其它线程想要再拿锁时得先排队,排到一个队列里。等线程0释放锁时,head指向的线程便可以去拿到锁以及更改state状态。
FIFO队列:先进先出的双向队列(双向链表实现)
state状态:0代表处于无锁状态,1代表处于有锁状态。
AQS中多个线程抢资源是通过CAS设置state状态,保证操作的原子性。
AQS既是公平锁也是非公平锁(AQS既可以实现公平锁也可以实现非公平锁)
1.新的线程与队列中的线程共同来抢资源,是非公平锁。因为队列中的线程已经等待了很久。
2.新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁。
ReentrantLock
特点
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
- 可中断(synchronized不可中断)
- 可以设置超时时间(synchronized,没有获取到锁,只能是阻塞状态)
- 支持公平锁和非公平锁(synchronized只支持非公平锁)
- 支持多个条件变量
- 与synchronized一样,都支持重入
示例:(要手动上锁与手动释放锁)
底层实现
ReentrantLock主要利用CAS+AQS队列来实现。ReentrantLock的底层就是继承了AQS。
它支持公平锁和非公平锁,两者的实现类似:
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
工作机制(与AQS基本相同)
- 线程来抢锁后使用 CAS 的方式修改 state 状态,修改状态成功为1,则让exclusiveOwnerThread 属性指向当前线程,获取锁成功。
- 假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部。
- 当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程。
- 如果是公平锁则按照队列先后顺序获取锁,如果是非公平锁则不在排队的线程也可以抢锁
synchronized和lock的区别
Lock锁
举例:
public class MyThread extends Thread {
static int ticket = 0;//0~99
//表示所有对象共用同一把锁
static Lock lock = new ReentrantLock();
@Override
public void run() {
while(true){
//加锁
lock.lock();
try {
if (ticket < 100) {
Thread.sleep(100);
ticket++;
System.out.println(
getName()+"正在卖第"+ticket+"张票!");
} else {
break;
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
//finally里面的代码一定会执行,
//所以这就保证了锁一定会被释放
lock.unlock();//释放锁
}
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
//线程安全问题:可用同步代码块 或 同步方法解决
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
t1.setName("窗口1");
t2.setName("窗口2");
t3.setName("窗口3");
t1.start();
t2.start();
t3.start();
}
}
区别
1.语法层面
synchronized 是关键字,源码在 JVM 中,用 C++ 语言实现
Lock 是接口,源码由 JDK 提供,用 Java 语言实现
使用 synchronized时,退出同步代码块锁会自动释放,而使用Lock时,需要手动调用unlock方法释放锁
2.功能层面
二者均属于悲观锁,都具备基本的互斥,同步,锁重入功能。
Lock 提供了许多 synchronized不具备的功能,例如公平锁,可打断,可超时,多条件变量。
Lock 有适合不同场景的实现,如ReentrantLock,ReentrantReadWriteLock(读写锁)
3.性能层面
在没有竞争时,synchronized做了很多优化,如偏向锁,轻量级锁,性能不赖
在竞争激烈时,Lock的实现通常会提供更好的性能
可打断
通过 lockInterruptibly()方法获取锁,如果线程没有获取到锁,而是被阻塞了,这时它处于想要获取锁的状态,这时它可以被打断。
打断的方法:t.interrupt()
可超时
使用 tryLock 方法上锁并设置一个等待时间,在这个等待时间内阻塞过程中线程都没有拿到锁的话该线程就会放弃等待和阻塞。
多条件变量
举例:
最终结果:
死锁
介绍
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁。
例:锁objA和锁objB互相嵌套,成为了死锁。
死锁的诊断(找到死锁的代码位置并解决)
可视化工具解决:
ConcurrentHashMap
介绍
ConcurrentHashMap是一种线程安全的高效map集合。
1.底层数据结构
- jdk1.7底层采用分段的数组+链表实现
- jdk1.8采用的数据结构跟hashmap1.8的结构一样,数组+链表/红黑二叉树
2.加锁的方式
- jdk1.7采用Segment分段锁,底层使用的是ReentrantLock
- jdk1.8采用CAS添加新节点,采用synchronized锁锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
底层实现
相比集合 hashtable 来说,分段锁的锁粒度变小了很多!!!
因为hashtable 是锁住整个数组,而分段锁只是锁住数组的一小段。
比如:两个线程对两个不同的分段数组里加数据时,不会互相阻塞住,性能较高。
JDK1.8 时:ConcurrentHashMap 中的锁的粒度更细了。
保证并发安全的三大特性
- 原子性:一次或多次操作在执行期间不被其他线程影响
- 可见性:当一个线程在工作内存修改了变量,其他线程能立刻知道
- 有序性:JVM对指令的优化会让指令执行顺序改变,有序性是禁止指令重排
并发程序出现问题的根本原因
1.原子性: 一个线程在 CPU 中操作不可暂停,也不可中断,要不执行完成,要不不执行
2.内存可见性:让一个线程对共享变量的修改对另一个线程可见
3.有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的
原子性: synchronized 和 Lock 都可以解决
内存可见性 建议volatile解决, synchronized, Lock
有序性 volatile解决
synchronized
锁:通过加锁解锁操作,保证临界区代码的原子性、可见性和有序性。
等待唤醒机制
原理
有关的方法
代码实现
public class Cook extends Thread{
@Override
public void run() {
//1:循环
while (true) {
//2:同步代码块
synchronized (Desk.lock) {
//3:判断共享数据是否到了末尾(到了末尾)
if (Desk.count == 0) {
break;
} else {//4:判断共享数据是否到了末尾(没到末尾)
//先判断桌子上是否有面条
if (Desk.foodFlag == 1) {
//如果有,则等待
try {
//让当前线程跟锁进行绑定,
//当使用Desk.lock.notifyAll()唤醒的时候,
//就会唤醒和这个锁Desk.lock绑定的所有线程。
Desk.lock.wait();//让当前线程跟锁进行绑定
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
} else {
//如果没有,则制作食物
System.out.println("厨师做了一碗面条");
//修改桌子状态
Desk.foodFlag = 1;
//叫醒消费者开吃
Desk.lock.notifyAll();
}
}
}
}
}
}
public class Foodie extends Thread{
@Override
public void run() {
//1:循环
while(true) {
//2:同步代码块
synchronized (Desk.lock){
//3:判断共享数据是否到了末尾(到了末尾)
if(Desk.count == 0){
break;
}else{//4:判断共享数据是否到了末尾(没到末尾)
//先判断桌子上是否有面条
if(Desk.foodFlag == 0){
//没有则等待
try {
Desk.lock.wait();//让当前线程跟锁进行绑定
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}else{//有则开吃
//把吃的总数-1
Desk.count--;
System.out.println("吃货在吃面条,还能再吃" + Desk.count +"碗!!!");
//吃完之后,唤醒厨师继续做
Desk.lock.notifyAll();
//修改桌子状态
Desk.foodFlag = 0;
}
}
}
}
}
}
public class Desk {
//作用:控制生产者和消费者的执行
//是否有面条
public static int foodFlag = 0;
//总个数
public static int count = 10;
//锁对象
public static Object lock = new Object();
}
public class Demo {
public static void main(String[] args) {
//生产者和消费者模式 (等待唤醒机制)
//创建线程的对象
Cook c = new Cook(); Foodie f =new Foodie();
c.setName("厨师"); f.setName("吃货");
//开启线程
c.start(); f.start();
}
}
运行结果:
阻塞队列方式实现等待唤醒机制
原理
阻塞队列的继承结构
代码实现
public class Cook extends Thread{
ArrayBlockingQueue<String> queue;
public Cook(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while(true){
//因为阻塞队列底层的put方法已经写好了锁的代码,
//所以我们就不用再手动写锁相关的代码了,不然容易形成死锁。
//不断地把面条放到阻塞队列当中
try {
queue.put("面条");
System.out.println("厨师做了一碗面条");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class Foodie extends Thread{
ArrayBlockingQueue<String> queue;
public Foodie(ArrayBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
while(true){
//不断地从阻塞队列当中获取面条
try {
String food = queue.take();
System.out.println(food);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
//利用 阻塞队列 实现等待唤醒机制
//创建阻塞队列的对象
ArrayBlockingQueue<String> queue =
new ArrayBlockingQueue<>(1);
//创建线程对象,并把阻塞队列传递过去
Cook c = new Cook(queue);
Foodie f = new Foodie(queue);
c.start();f.start();
}
}
线程池
自定义线程池核心参数
public class MyRunnable implements Runnable{
@Override
public void run() {
for (int i = 1; i <= 100; i++) {
System.out.println(Thread.currentThread().getName() + "-----" + i);
}
}
}
//1:获取线程池的对象
ThreadPoolExecutor pool = new ThreadPoolExecutor(
3,//核心线程数量,不能小于0
6,//最大线程数,不能小于0(最大线程数 >= 核心线程数)
60,//临时线程最大存活时间,不能小于0
TimeUnit.SECONDS,//临时线程最大存活时间单位
new ArrayBlockingQueue<>(3),//阻塞队列
Executors.defaultThreadFactory(),//创建线程工厂
new ThreadPoolExecutor.AbortPolicy()//任务的拒绝策略
);
//2:提交任务
pool.submit(new MyRunnable());
pool.submit(new MyRunnable());
pool.submit(new MyRunnable());
pool.submit(new MyRunnable());
pool.submit(new MyRunnable());
以上代码解释:
前三个任务会分三个核心线程去完成,
后两个任务先放到阻塞队列中等待。
那三个核心线程执行完自己的任务空闲下来时,就会去按顺序完成阻塞队列中的任务
临时线程:临时线程空闲时间超过最大空闲时间就会被释放。
核心线程不会被释放。
线程池执行原理
核心原理
1.创建一个池子,池子中是空的。
2.提交任务时,池子会创建新的线程对象,任务执行完毕,线程归还给池子。下回再次提交任务时,不需要创建新的线程,直接复用已有的线程即可。
3.但是如果提交任务时,池子中没有空闲线程(空闲下来的核心线程),也无法创建新的线程(核心线程数量已经到达最大值了),任务就会排队等待
利用JAVA工具类获得线程池对象
这种利用工具类创建线程池对象的方法不止于此,后面介绍线程池的种类时还有更为深入的说明。
不推荐用这种方法创建,最好还是用自定义线程池的方法(可以根据硬件条件、不同的业务需求等设置不同的参数)。
三个临界点
不断的提交任务,会有以下三个临界点:
1.当核心线程满时,再提交任务就会排队
2.当核心线程满,队伍满时,会创建临时线程
3.当核心线程满,队伍满,临时线程满时,会触发任务拒绝策略
任务拒绝策略
其中的 CallerRunsPolicy:用调用者所在的线程来执行任务,也就是使用主线程来执行任务。
线程池中常见的阻塞队列
workQueue --- 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建临时线程执行任务:
- ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO
- LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO
- DelayedWorkQueve:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的任务
- SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作
有界指可以设置阻塞队列容量。
默认无界:LinkedBlockingQueue队列默认容量为Integer的最大值。
LinkedBlockingQueue底层是两把锁,分别管控队列的入队和出列操作的线程安全问题,而ArrayBlockingQueue底层只有一把锁管控,
相比之下 LinkedBlockingQueue 的锁的粒度更精细一点,所以LinkedBlockingQueue 的性能较高一些。
核心线程数设置为多大
最大并行数(CPU核数)
例
说明这台电脑的CPU是4核8线程的,最大并行数(CPU核数)就是8。
最大并行数计算方法:
计算方法
上下文切换
多线程编程中一般线程的个数都大于CPU核心的个数,而一个CPU核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效的执行,CPU采取的策略是为了每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让其他线程使用,这个过程就属于一次上下文切换。
当线程池中核心线程数量过大时,线程与线程之间会争取CPU资源,这样就会导致上下文切换。过多的上下文切换会增加线程的执行时间,影响了整体执行的效率;
CPU密集型任务
例如:计算型代码,Bitmap转换,Gson转换等计算型任务是CPU密集型任务
这种任务消耗的主要是CPU资源。线程在执行任务时会一直利用CPU,CPU使用率很高,而I/O执行很快。那么这时候就需要尽量减少线程上下文切换这种额外的时间开销。
所以核心线程数设置为CPU核数 + 1就好。
比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
IO密集型任务
例如:文件读写,DB读写,网络请求等任务是IO密集型任务
这种任务用大部分的时间来处理 IO 交互,即大部分时间都阻塞在IO上,而线程在处理 IO 的时间段内不会占用 CPU 来处理(CPU只需要对相应的文件进行通知一声,不需要进行过多的运算),这时就可以将CPU交出给其它线程使用。因此在 IO 密集型任务的应用中,我们可以多配置一些线程。
一般来说核心线程数设置为CPU核数 * 2 + 1
混合型任务
混合型任务:既包含CPU密集型又包含I/O密集型。
核心线程数 = CPU核数 * (总时间(CPU计算时间+CPU等待时间)/ CPU计算时间)
CPU计算时间和CPU等待时间可以通过thread dump工具来计算。
总结
CPU密集型 可以理解为就是处理繁杂算法的操作,对硬盘等操作不是很频繁,比如一个算法非常之复杂,可能要处理半天,而最终插入到数据库的时间很快。
IO密集型可以理解为简单的业务逻辑处理,比如计算1+1=2,但是要处理的数据很多,每一条都要去插入数据库,对数据库频繁操作。
JAVA开发的项目大部分是IO密集型的。
线程池的种类
在java.util.concurrent.Executors类中提供了大量创建线程池的静态方法,常见的就有4种。
1.创建使用固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
核心线程数与最大线程数一样,没有临时线程。
适用于任务量已知,相对耗时的任务。
2.单线程化的线程池
它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行。
核心线程数和最大线程数都是1。
适用于按照顺序执行的任务。
3.可缓存线程池
核心线程数为0,最大线程数是 Integer.max.value
阻塞队列为 SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
适合任务数比较密集,但每个任务执行时间较短的情况。
4.提供了"延迟"和"周期执行"功能的ThreadPoolExecutor
延迟:可以延迟任务的执行,即可以设置任务的开始执行时间为一定时间之后。
弊端
OOM:内存溢出
所以不建议用Executors创建线程池。
CountDownLatch
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
1.其中构造参数用来初始化等待计数值
2.await()用来等待计数归零
3.countDown()用来让计数减一
Semaphore信号量
可以使用Semaphore信号量控制某个方法允许访问线程的数量。
Semaphore信号量,是 JUC 包下的一个工具类,底层是 AQS,我们可以通过其限制执行的线程数量。
使用场景: 通常用于那些资源有明确访问数量限制的场景,常用于限流。
Semaphore使用步骤:
1.创建Semaphore对象,可以给一个容量。
2.semaphore.acquire():请求一个信号量,这时候的信号量个数减一(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)。
3.semaphore.release():释放一个信号量,此时信号量个数加一。
ThreadLocal
介绍
ThreadLocal是多线程中对于解决线程安全的一个并发工具类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。
- ThreadLocal可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题。
- ThreadLocal同时实现了线程内的资源共享。
它提供了线程局部变量的功能。线程局部变量是线程独有的变量,每个线程都可以通过 ThreadLocal 实例来访问自己独立的变量副本,而不会和其他线程的副本产生冲突。
场景案例:
- 保持线程独立性:当您希望某个变量对每个线程都是独立的时候,可以使用 ThreadLocal。
例如,使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的Connection上进行数据库的操作,避免A线程关闭了B线程的连接。
- 传递数据:在复杂的业务逻辑中,有时候需要在线程的多个方法间传递数据,但又不想通过方法参数显式传递,此时可以使用 ThreadLocal。
- 线程安全:ThreadLocal 可以避免因多线程操作共享变量而导致的线程安全问题。
代码举例
set(value) )设置值 get()获取值 remove()清除值
实现原理&源码解析
总结
每个线程内有一个ThreadLocalMap类型的成员变量,用来存储资源对象
a.调用set方法,就是以ThreadLocal自己作为key,资源对象作为value,放入当前线
程的ThreadLocalMap集合中
b.调用get方法,就是以ThreadLocal自己作为key,到当前线程中查找关联的资源值
c.调用remove方法,就是以ThreadLocal 自己作为key,移 ,移除当前线程关联的资源值
注意事项
- 内存泄漏:如果 ThreadLocal 中存储了大量的数据,且线程长时间运行(如线程池中的线程),那么可能会造成内存泄漏。因此,当线程结束后,应该调用 ThreadLocal 的 remove 方法来清除线程的局部变量。
- 不易调试:由于 ThreadLocal 变量对其他线程不可见,因此在调试时可能会增加难度。
- 不可用于共享:如果您需要在线程间共享变量,那么 ThreadLocal 不是正确的选择。
ThreadLocal的内存泄漏问题
Java对象的引用类型
有四种引用类型:强引用,软引用,弱引用,虚引用
内存泄漏
ThreadLocalMap中的key是弱引用,值为强引用,key会被GC释放内存,关联value的内存并不会释放。需要主动 remove释放 key,value