文章目录
0、前言:为什么要使用多线程
- CPU核心数量提升了。
- 对比多进程,多线程的上下文切换更快,开销更小。
0.1、进程和线程的区别
- 进程是指一个具有一定独立功能的程序,在一个数据集合上的一次动态执行过程,是CPU资源调度的基本单位。
- 线程是轻量级的进程。
- 区别:
- 进程是资源分配的基本单位。线程只能共享进程资源,不能拥有资源。
- 同一进程中线程切换不会引起进程切换;不同的进程中线程切换会引起进程的切换。
- 进程的创建和销毁,系统需要单独为其分配和回收,开销远远大于线程的创建和销毁;进程的上下文切换要保存更多的信息。
1、线程同步和互斥?
1.1、互斥
- 是指线程通过竞争进入临界区(共享资源),为了防止访问冲突,在有限的时间内只允许一个线程使用共享资源。
- 是通过竞争对共享资源的独占使用,彼此之间不需要知道对方的存在,执行顺序的乱序的。
1.2、同步
- 多个线程彼此合作,通过一定的逻辑关系共同完成一个任务。
- 同步往往包含互斥,同时对临界区会按照某种逻辑顺序进行访问。
1.3、区别
- 同步是协调多个相互关联的线程合作完成任务,彼此之间知道对方存在,执行顺序是有序的。
- 互斥是竞争对共享资源的独占使用,彼此之间是不知道对方存在,执行顺序的乱序的。
1.4、实现方式
- synchronized、volatile、lock、join。
2、线程的状态有哪些?
- 初始状态(NEW):此时线程被构建,但还未调用start方法。
- 运行状态(RUNNABLE):java线程将操作系统中就绪和运行两种状态笼统的成为运行中。
- 阻塞状态(BLOCKED):线程阻塞。
- 等待状态(WAITTING):线程进入等待状态,表示当前线程需要等待其他线程做出一个特定动作(通知或者中断)。
- 超时等待(TIME_WAITING):与WAITTING不同,可以在指定时间内自行返回的。
- 终止状态(TERMINATED):表示当前线程已经执行完毕。
2.1、状态转换
3、wait和sleep的区别
- sleep是Thread类中的静态方法,wait是Object类中的成员方法。
- 调用sleep方法之后,线程不会释放对象锁。调用wait方法之后,线程会放弃对象锁。
- sleep会有异常抛出,需要进行异常处理,wait是没有异常的。
- sleep方法可以在任何地方使用,wait只能在同步方法或者同步代码块中使用且要配合notify()。
4、Java乐观锁和悲观锁
- 乐观锁:假设数据一般情况下不会造成冲突,只是在更新的时候判断再次期间别人是否做了更新。适用于读操作多的场景,可以提高程序的吞吐量。
实现:- CAS
- 版本号控制
- 优点:读多写少并发场景下,可以避免数据库加锁的开销,提高DAO层的响应性能。
- 缺点:写多读少情况下,会导致CAS空旋,开销比悲观锁还大。
- 悲观锁:假设读取数据的时候默认其他线程会更改数据,因此需要进行加锁操作。
分类:共享锁(读锁),只能读不能改;排它锁(写锁):不能与其他锁共存,如果一个事务获取了一个数据行的排它锁,其他数据就不能在获取改行的其他说,包括写读锁和写锁,获取排它锁的事务是可以对数据行读取和修改的。 - 优点:适合写多读少的并发环境,做到数据的安全性。
- 缺点:加锁增加系统开销,数据处理吞吐量低。
5、volatile
- volatile是JVM提供的轻量级的同步机制
- 三大特性:
- 保证可见性
- 不保证原子性
- 禁止指令重排。
5.1、JMM
-
JMM是Java内存模型,是一个抽象的概念,实际并不存在,描述的是一组规范。通过规范定义了程序中各个变量的访问方式
-
关于同步规定:
- 线程解锁前,必须把共享变量的值刷新回主内存。
- 线程加锁前,必须读取主内存的最新值到自己的工作内存。
- 加锁和解锁是同一把锁。
-
主内存:即计算机内存。工作内存可以理解线程有一个位置去保存从主内存拷贝过来的东西。
-
JVM运行程序的实体是线程,每个线程创建的时候JVM都会会它创建一个工作内存(栈空间),工作内存是私有数据区域,JMM规定所有变量都存储在住内存,所有线程都可以访问。但是但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
-
即:JMM内存模型的可见性,就是主内存区域的值被某个线程写入更改之后,其他线程会马上知道更改后的值,并重新得到更改后的值。
-
数据传输的效率:硬盘<内存<缓存<CPU。
5.2、可见性
- 如何验证volatile的可见性?(尚硅谷视频中有代码示例)
- 即:每个线程对主内存的值进行拷贝之后在自己的工作内存中进行修改之后,必须写回主内存,这样才能保证其他线程得到修改过后的值。
5.3、不保证原子性
- 原子性:要么同时成功,要么同时失败。(尚硅谷视频中有代码示例)
- 出现数值丢失:两个线程都获得了主内存中的值,然后同时进行修改,修改之后需要写回主内存(可见性),可能出现1号线程在修改的时候,2号也在修改,然后1号进行写回主内存这个操作的时候,2号线程也同时写回了,那么1号线程写回的数据必然会被后面的2号线程写回的值覆盖掉,这就造成了数值丢失。
5.3.1、数值丢失解决
- 在方法前面加上
synchronized
进行同步。(synchronized是一个重量级锁)。 - 尚硅谷在视频中提到引入原子类进行解决,即:JUC包下的原子包装类(java.util.concurrent.atomic),选择相对应的原子包装类进行解决。
5.4、禁止指令重排
- 计算机在执行程序的时候,会为了提升性能,在编译器和处理器会对指令进行重排。那么在多线程环境中线程交替执行,由于编译器对指令进行重排之后,两个线程中使用的变量能否保证一致性是无法确定的。
- 处理器在进行指令重排的时候必须要考虑到数据依赖性。
5.4.1、指令重排举例
- 举例1:
public void mySort() { int x = 11; int y = 12; x = x + 5; y = x * x; }
- 在单线程的环境下,执行顺序永远都是1、2、3、4.
- 但是在多线程环境下可能出现2、1、3、4;1、3、2、4。但永远不会出现4、3、2、1;3、4、1、2等这样的情况,因为指令重排必须考虑数据的依赖性,即:对于4来说要依赖y和x的声明,所以它是不能首先执行的。
2.举例2:
int a,b,x,y = 0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
结果:x = 0; y = 0
由于不存在数据依赖性,那么,编译器是可以对数据进行重排的。
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x = a; | y = b; |
此时的结果:x=2;y=1;
指令重排之后,结果和最开始的不一样了,所以为了防止这样结果出现,volatile就规定禁止指令重排,就是为了保证数据一致性。
5.4.2、volatile与指令重排
-
volatile实现禁止指令重排,从而避免了多线程环境下程序出现乱序执行的现象。如如何完成的?
-
内存屏障:又称内存栅栏,是一个CPU指令,作用:
- 保证特定操作的顺序。
- 保证某些变量的内存可见性。
-
由于编译器和处理器都能进行指令重排,如果在指令中间插入一条内存屏障,那么就会告诉编译器和CPU,不管什么指令都不能呵这条内存屏障指令重排序,即:通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。
-
那么volatile的写和读的时候,加入内存屏障,防止出现重排。
5.5、volatile的应用-单例模式
- 单例模式采用两段检查锁的机制进行
- 问题1:为什么不采用synchronized对方法进行同步?
- 因为synchronized是一个重量级的同步机制,使用synchronized之后,对于一个方法只允许一个线程进行访问获取实例,但是为了保证数据一致性,导致并发性降低。
- 问题2:不加volatile只使用DCL机制会出现什么问题?
-
此时并不一定是线程安全的,因为有指令重排的存在。
-
此时如果没有加volatile,那么某一个线程执行到第一次检测的时候,读取到instance != null,instance的引用对象可能未完成实例化,因为instance = new SingletonDemo()是可以分为三步完成的:
memory = allocate(); // 1、分配对象内存空间 instance(memory); // 2、初始化对象 instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
-
但是此时发现2和3并不存在数据依赖,那么就可以进行重排的。
memory = allocate(); // 1、分配对象内存空间 instance = memory; // 3、设置instance指向刚刚分配的内存地址,此时instance != null,但是对象还没有初始化完成 instance(memory); // 2、初始化对象
-
那么此时会造成的问题就是,在进行重排之后,试图获取instance的时候,得到了一个null,因为对象还没有进行初始化,此时的初始化是最后执行的,那么有一个线程读到这个instance = null的时候,就会进行初始化,那么就造成了线程不安全的问题。
-
所以必须加上volatile。
-
- 问题3:为什么要采用两次判断?两次判断的作用和不同在哪里?
- 第一次if检查的时候,可能某个线程在判断的时候为空,二另外一个线程已经执行到了初始化的位置。(就是现在虽然这个instance为空,但是前面有一个线程进行实例化了)。
- 第二次检查的时候就是判断此时instance是否为null。(原本是只有第二次的if,只用来做对象实例的判断,为空就进行实例化)。
- 代码:
public class SingletonDemo {
private static volatile SingletonDemo instance = null;
private SingletonDemo () {
System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo");
}
public static SingletonDemo getInstance() {
if(instance == null) {
// a 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处
synchronized (SingletonDemo.class) //b
{
//c不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入
if(instance == null) {
// d 此时才开始初始化
instance = new SingletonDemo();
}
}
}
return instance;
}
public static void main(String[] args) {
// // 这里的 == 是比较内存地址
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
// System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
for (int i = 0; i < 10; i++) {
new Thread(() -> {
SingletonDemo.getInstance();
}, String.valueOf(i)).start();
}
}
}
6、synchronized
- 经过编译之后:monitorenter+代码逻辑+monitorexit组成。monitorenter和monitorexit这两个字节码指令都需要一个reference类型的参数来指定要锁定和解锁的对象。如果Java源码中synchronized明确指明了参数对象,那就以这个对象的引用作为reference。如果没有明确指明,那么根据synchronized修饰的方法类型决定,是取代码所在的对象实例还是取类型对应的Class对象来作为线程持有的锁。
- 在monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。
7、synchronized偏向锁、轻量级锁、重量级锁的升级过程
JDK1.6为了减少获得锁的释放带来的性能消耗,引入偏向锁和轻量级锁。级别:无锁、偏向锁、轻量级锁、重量级锁。
- 偏向锁:大多数时候不存在锁竞争的,通常是一个线程多次获得同一个说,所以如果每次都竞争锁会付出很多代价。因此引入偏向锁。
- 原理:线程1访问代码块并获取说对象的时候,会在Java对象头和栈帧中记录偏向锁的线程ID(偏向锁不会主动释放锁),此后,先后1再次获取锁的时候,比较当前线程的ID和Java对象头中的线程ID是否一致,一致就不需要使用CAS加锁、解锁;不一致(存在竞争,有其他线程,偏向锁不会主动释放,Java对象头存储的还是线程1的ID),此时查看Java对象头中记录的线程1是否存活,没有存活,那么锁对象被重置为无锁状态,其他线程可以竞争将其设置为偏向锁;存活,那么立刻查找线程1的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的进程。
- 轻量级锁:线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。 自旋锁简单来说就是让线程2在循环中不断CAS。但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。
- 重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
- 不同锁的对比:
8、synchronized与ReentrantLock区别
-
相同点:
- 都是使用加锁的方式进行同步。
- 都是阻塞式同步,即:当一个线程获得对象说,进入同步块,其他访问该同步块的线程都必须在同步块外面进行等待。
- 都是可重入锁。
-
不同点:
- synchronized是Java里的关键字,需要JVM来实现,就是阻塞的线程由JVM调度器来进行唤醒。ReentrantLock是显示的加锁和解锁,更加灵活。
- synchronized实现原理是在修饰的地方经过遍历之后生成两条字节码指令,指令指定一个引用类型来尝试获得对象锁,锁的互斥量存放在对象头中。ReentrantLock内部使用AQS队列同步器来实现的,使用一个int类型的成员变量来表示同步状态,使用CAS操作来获取锁。
- synchronized获得的锁只能是非公平锁。ReentrantLock是可以通过维护一个FIFO队列来实现公平锁的获取。
8.1、性能比较
在Synchronized优化以前,synchronized的性能是比ReenTrantLock差很多的,但是自从 Synchronized引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用synchronized
9、AQS作用以及实现原理
见AbstractQueuedSynchronizer队列同步器源码解析
10、JUC中的原子变量以及原子操作如何实现的
10.1、CAS底层原理
首先我们先看看 atomicInteger.getAndIncrement()方法的源码
从这里能够看到,底层又调用了一个unsafe类的getAndAddInt方法
- 在源码中很多操作都是基于unsafe的。
- Unsafe是CAS的核心类,由于Java方法无法直接访问底层系统,需要通过本地(Native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定的内存数据。Unsafe类存在sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为Java中的CAS操作的执行依赖于Unsafe类的方法。
- 为什么Atomic修饰的包装类,能够保证原子性,依靠的就是底层的unsafe类。
10.2、valueOffset
- 表示该变量值在内存中的偏移地址,因为Unsafe就是根据内存偏移地址获取数据的。
从这里我们能够看到,通过valueOffset,直接通过内存地址,获取到值,然后进行加1的操作。
10.3、变量value用volatile修饰
保证了多线程之间的内存可见性
var5:就是我们从主内存中拷贝到工作内存中的值(每次都要从主内存拿到最新的值到自己的本地内存,然后执行compareAndSwapInt()在再和主内存的值进行比较。因为线程不可以直接越过高速缓存,直接操作主内存,所以执行上述方法需要比较一次,在执行加1操作)
那么操作的时候,需要比较工作内存中的值,和主内存中的值进行比较
假设执行 compareAndSwapInt返回false,那么就一直执行 while方法,直到期望的值和真实值一样
- val1:AtomicInteger对象本身
- var2:该对象值的引用地址
- var4:需要变动的数量
- var5:线程中工作内存的值
- 用该当前主内存的值与var5比较
- 如果相同,更新var5 + var4 并返回true
- 如果不同,继续取值然后再比较,直到更新完成
这里没有用synchronized,而用CAS,这样提高了并发性,也能够实现一致性,是因为每个线程进来后,进入的do while循环,然后不断的获取内存中的值,判断是否为最新,然后在进行更新操作。