前言
上一篇介绍了《Java多线程的作用》,使用场景和创建方式等基础,本篇主要介绍:
- 多线程的安全问题
- 从指令集层面分析线程安全问题产生原因
- 多线程安全问题解决方案
- 锁分类
- synchronized和Lock的底层实现原理
文章涵盖广而全,对工作和面试都有很大帮助,值得收藏认真阅读,不错的话记得点赞,关注支持哦!
线程运行机制
一旦调用start方法,线程处于runnable状态【可运行状态】。也就是可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。(Java的规范说明没有将它作为一个单独状态。一个正在运行中的线程仍然处于可运行状态。)
一旦一个线程开始运行,它不必始终保持运行。运行中的线程被中断,目的是为了让其他线程获得运行机会。线程调度的细节依赖于操作系统提供的服务。抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行机会。当选择下一个线程时,操作系统考虑线程的优先级。
现在所有的桌面以及服务器操作系统都是用抢占式调度。但是,像手机这样的小型设备可能使用协作式调度,在这样的设备中,一个线程只有调用yield方法,或者被阻塞或等待时,线程才失去控制权。
在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器依然采用时间片机制。
记住,在任何给定时刻,一个可运行的线程可能正在运行也可能没有运行(这就是为什么将这个状态称为可运行而不是运行)
Java的多线程可以充分利用CPU资源提高计算速度和处理后台任务等,而且线程之间的运行机制是抢占式,或者说是随机的,这就会导致多个线程对共享数据操作时可能出现错误的结果,对于多线程并发时会使程序出现bug的代码称作线程不安全的代码,这就是线程安全问题
存在线程安全问题程序
比如公司研发了一款手机,提供两个售货渠道卖10部手机,每一个线程就是一个售货渠道,当然最多只能卖出10部不能超卖
打印结果:
运行结果发现出现了0号手机,卖出了11部,明显是有问题的。你也可以试着运行,每次的运行结果不一样,而且出现这种BUG也是随机的,你可能运行十几二十次都不会出现这问题
问题分析
宏观分析
要明确一个前提是只有得到CPU的时间片线程才会被执行,而且CPU不保障一次将线程执行完,也就是说,CPU会在线程之间切换执行,上述例子出现超卖的原因也在这里
- 当还有最后一部手机时,线程2经过while循环判断进入循环体,这时还没有对手机进行售卖,线程2的时间片用完,线程挂起,CPU开始执行线程1
- 线程1这时判断while循环条件,仍然成立,进入线程体,输出【售卖渠道1卖出第1部手机】,这时切换到线程2
- 上次线程2已经通过了while循环的判断,所以继续执行,不会再次进行判断,但是stocks值已经被线程1减去变成0,这时再输出就是【售卖渠道2卖出第0部手机】
- 所以如果是三个线程在卖手机,也有可能出现【卖出第-1部手机】的情况,你理解吗?
微观指令集层面分析
上边我们在操作共享变量stocks时使用了stocks--这样的语法,自减操作也有大学问
stocks--:会对变量进行-1操作,--在变量之后,所以是后--,意思是如果变量参与了运算,则先完成运算再进行-1操作,比如上述例子与字符串进行相加运算,所以stocks变量会先于字符串完成拼接输出数据之后再对变量进行 -1 操作
而且程序运行时需要交给CPU执行,系统在执行运算时会将代码转换为指令集进行运算,在指令集方面,stocks--这样的一个自减操作会被分成三个指令操作:
- 将内存中的变量值加载到寄存器,我们暂且将这个操作表记作iload
- 在寄存器中执行自增操作,将这个操作记为isub
- 将寄存器的值保存到内存中,将这个操作记为istore
- 当然while判断也是将值从内存中加载出来做判断
情况1:线程之间指令集无交叉,运行结果与预期相同,线程1从内存加载值,运算之后再将值存进内存,线程2获取值,发现值为0,while判断不成立
情况2:线程之间指令集存在交叉,结果可能存在问题,指令交叉计算后得知没有及时刷新进内存,导致另外的线程获取到的是旧值,就会存在少减情况
情况3:指令完全交叉,现象与情况2一样,出现库存少减现象
根据上边的几种情况分析,发现线程运行时没有出现指令交叉结果是预期的,如果出现指令交叉就会存在库存少减现象,是因为自减操作不是原子的是可以再分割的,线程之间独立,线程内计算的值并没有直接刷新进内存,导致别的线程并不会得到最新的数据,多线程并发执行时很可能出现指令交叉,导致线程安全问题,出现错误结果。
解决上述线程不安全问题,我们常用的方法就是加锁
什么是加锁
在Java多线程中,当两个或以上线程对同一数据进行操作时,就会产生【竞争条件】的现象,这种现象产生的根本原因是因为多个线程在对同一个数据进行操作,此时对该数据的操作是非“原子化”的,可能前一个线程对数据的操作还没有结束,后一个线程又开始对同样的数据开始进行操作,这就可能会造成数据结果的变化未知。
为了解决由于【抢占式执行】导致的线程安全问题,我们可以对共享数据进行加锁,可以理解为给多线程操作的共享数据设置一个操作权限,谁拿到这个锁,谁就有权利操作共享数据,当一个线程拿到共享数据的锁后,就会把共享数据锁起来,其他线程如果也要操作这个共享数据,需要等待已经获取到锁的线程执行完之后释放锁,其他拿个线程得到这个锁,谁就可以操作共享数据。
举个例子:有一家饭店的包间非常不错,很多人都想在包间中就餐。当包间被顾客预定之后就相当于被上了锁,其他顾客必须等待上一个顾客享用完服务之后才可再预定使用,预定到的就会再次对包间上锁,其他顾客无法享用这个包间。这样就不会乱糟糟的了是吧,不然就跟没有秩序一样,谁都可以进包间里边就会发生冲突。这里的顾客就是一个一个的线程,这里的包间就是共享数据,预定包间成功就相当于加的锁
当你使用完之后,释放锁,其他线程竞争锁,当一个线程抢到锁之后,就会进入套房享用服务
当然,如果世界上只有一个客户,也就是只有一个线程就不需要加锁了,对吧!
如何加锁
Java中最常见的是使用 synchronized 加锁。synchronized 是互斥锁,有互斥效果,即同一时刻只能有一个线程操作共享数据,某个线程执行到 synchronized 中时, 其他线程如果也执行这块代码,就会阻塞等待。线程进入 synchronized 修饰的代码块, 相当于 加锁,退出 synchronized 修饰的代码块, 相当于 解锁
加锁也可以称为线程同步,同步也好理解,就是一个一个来嘛
方式1:使用synchronized关键字修饰方法,这样会使方法所在的对象加上一把锁
实现类:
测试类:
上边的代码可以解决线程安全问题,但是因为while条件中直接判断的共享资源,所以将while直接锁进嘞小房间,所以所有的手机都会被同一个线程售出,比如:线程1获取到锁资源后上锁,进入while循环,沉迷其中不可自拔,一口气消费完才释放锁。我们可以通过以下代码优化,实现线程交替运行:
定义 flag 变量标记是否还有库存,while循环判断库存标记,这样可以保障当线程1判断while之后挂起还没有调用售卖方法时仍然可能丢失CPU执行权,切换到其他线程执行。
运行结果:在运行结果截图中,发现写了一个sleep方法,这是为了让线程进入超时等待可以释放CPU执行权,来达到切换线程的目的,实际开发中是不会使用sleep方法的,所以上边贴出的代码中并没有sleep方法调用
为可看出效果,也可以将库存调为10万台,有充分的资源支撑线程切换,可以看出下图同样线程1和线程2之间切换,并且没有出现超卖现象
方式2:使用synchronized关键字对代码段进行加锁,但是需要显式指定加锁的对象。
运行结果:
方式3:使用synchronized关键字修饰静态方法,相当于对当前类的类对象进行加锁
常见的用法差不多就是这些,对于线程加锁(线程拿锁),如果两个线程同时拿一个对象的锁,就会产生锁竞争,两个线程同时拿两个不同对象的锁不会产生锁竞争。 对于synchronized这个关键字,它的英文意思是同步,但是同步在计算机中是存在多种意思的,比如在多线程中,这里同步的意思是“互斥”;而在IO或网络编程中同步则是另一个意思
三种方式锁对象区别:
- 同步方法:锁对象是this
- 同步代码块:锁对象可以是任意对象,例子中使用的是this
- 静态同步代码块:锁对象是当前类的class对象即,类.class
synchronized 的工作过程:
- 获得互斥锁lock
- 从主内存拷贝变量的最新副本到工作的内存
- 执行代码
- 将更改后的共享变量的值刷新到主内存
- 释放互斥锁unlock
综上,synchronized关键字加锁有如下性质:互斥性,刷新内存性,可重入性。
所谓可重入,即一个线程已经获得了某个锁,当这个线程要再次获取这个锁时,依然可以获取成功,不会发生死锁的情况。synchronized就是一个可重入锁。
可重入的条件
- 不在函数内使用静态或全局数据。
- 不返回静态或全局数据,所有数据都由函数的调用者提供。
- 使用本地数据(工作内存),或者通过制作全局数据的本地拷贝来保护全局数据。
- 不调用不可重入函数
可重入与线程安全
一般而言,可重入的函数一定是线程安全的,反之则不一定成立。在不加锁的前提下,如果一个函数用到了全局或静态变量,那么它不是线程安全的,也不是可重入的。如果我们加以改进,对全局变量的访问加锁,此时它是线程安全的但不是可重入的,因为通常的加锁方式是针对不同线程的访问(如Java的synchronized),当同一个线程多次访问就会出现问题。只有当函数满足可重入的四条条件时,才是可重入的
synchronized是可重入锁
从设计上讲,当一个线程请求一个由其他线程持有的对象锁时,该线程会阻塞。当线程请求自己持有的对象锁时,如果该线程是重入锁,请求就会成功,否则阻塞。
我们回来看synchronized,synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的。
在Java内部,同一个线程调用自己类中其他synchronized方法/块时不会阻碍该线程的执行,同一个线程对同一个对象锁是可重入的,同一个线程可以获取同一把锁多次,也就是可以多次重入。原因是Java中线程获得对象锁的操作是以线程为单位的,而不是以调用为单位的。
synchronized可重入锁的实现
每个锁关联一个线程持有者和一个计数器。当计数器为0时表示该锁没有被任何线程持有,那么任何线程都都可能获得该锁而调用相应方法。当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器计为1。此时其他线程请求该锁,则必须等待。而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增。当线程退出一个synchronized方法/块时,计数器会递减,如果计数器为0则释放该锁。
加锁后分析
当下成1获取到锁对象之后就会将共享资源锁起来【lock】,当线程1处理完之后释放锁【unlock】,其他线程来竞争这把锁,谁得到锁谁就将资源锁住【lock】,依次释放和获得锁,没有获取到锁的线程就会进入阻塞状态
加锁后线程就是串行执行,与单线程其实没有很大的区别,那多线程是不是没有用了呢?但是对方法加锁后,线程运行该方法才会加锁,运行完该方法就会自动解锁,况且大部分操作并发执行是不会造成线程安全的,只有少部分的修改操作才会有可能导致线程安全问题,因此整体上多线程运行效率还是比单线程高得多。
ReentrantLock可重入锁
从JDK5.0开始,Java提供了更强大的线程同步机制——通过显示定义同步锁对象来实现同步。同步锁使用Lock对象充当。该锁对象在Java的JUC包中
java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问。每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象
ReentrantLock类实现了Lock,ta拥有与synchronized 相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,从名字上可以看出该所对象是可重入锁,可以显式加锁,释放锁
使用 ReentrantLock 的时候,建议把 Lock 和 方法体 放在 try{} 代码块中,然后释放锁 unlock() 放在 finally{} 代码块中保证锁释放成功~,如果线程发生异常意外终止,锁没有释放成功,别的线程也获取不到锁,就会出现死锁,也就是谁都拿不到锁,谁都运行不了程序
synchronized和Lock加锁区别
- lock是一个接口,而synchronized是java的一个关键字
- synchronized是隐式的加锁和解锁,以获取锁的线程执行完同步代码,释放锁,或线程执行发生异常,jvm会让线程释放锁,lock是显示的加锁和解锁,在finally中必须释放锁,不然容易造成线程死锁
- synchronized可以作用在方法和代码块上,而lock只能作用在代码块上
- synchronized是阻塞式加锁,假设A线程获得锁,B线程等待。如果A线程阻塞,B线程会一直等待,而lock中获取锁分情况而定,Lock有多个锁获取的方式,可以通过trylock方法尝试获取锁,线程可以不用一直等待,支持非阻塞式加锁
- synchronized锁状态无法判断,Lock锁状态可以判断
- synchronized 是可重入 不可中断 非公平锁,Lock有多种实现,可以是可重入 可判断 可公平锁(两者皆可
Lock锁
以下是Lock接口的源码,简单翻译如下:
lock方法:
运行结果:
tryLock方法:
tryLock就是尝试获取锁,如果所被别的线程获取,则直接放弃获取,不阻塞,好比追一个小姐姐,人家有对象了,直接放弃,而lock则是等着【分手接盘】
而tryLock(long time, TimeUnit unit),则是锁被别的线程拿到,会等待指定时间,如果还没获取到就放弃,好比给小姐姐一段时间分手,如果没分就拉到,分了就接盘
运行结果:
ReentrantLock源码分析
下方代码是从JDK源码中摘录出来的,对部分代码做了注释,可以细品一下
ReentrantLock在创建对象时可以选择是否为公平锁,默认为非公平锁。面试时Java中的锁分类也是高频问点!在下边也为大家介绍到:
锁分类
公平锁/非公平锁
- 公平锁是指多个线程按照申请锁的顺序来获取锁。
- 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
- 对于ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
- 对于synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁
可重入锁
- 可重入锁又名递归锁,是指同一个线程如果获取到锁对象,在线程内的其他代码块中部会自动获取锁。
- 对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是ReentrantLock重新进入锁。
- 对于synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁
独享锁/共享锁
- 独享锁是指该锁一次只能被一个线程所持有。
- 共享锁是指该锁可被多个线程所持有。
- 对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。
- 读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。
- 独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。
- 对于synchronized而言,当然是独享锁
互斥锁/读写锁
- 上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。
- 互斥锁在Java中的具体实现就是ReentrantLock
- 读写锁在Java中的具体实现就是ReadWriteLock
乐观锁/悲观锁
- 乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。
- 悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。
- 乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重新的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。
- 从上面的描述我们可以看出,悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
- 悲观锁在Java中的使用,就是利用各种锁。
- 乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新
分段锁
- 分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。
- 我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7与JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。
- 当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。
- 但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。
- 分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作
偏向锁/轻量级锁/重量级锁
- 这三种锁是指锁的状态,并且是针对synchronized。在Java 5通过引入锁升级的机制来实现高效synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
- 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
- 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
- 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低
自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU
两种锁的底层实现方式
synchronized:Java是用字节码指令来控制程序(这里不包括热点代码编译成机器码)。在字节指令中,存在有synchronized所包含的代码块,那么会形成2段流程的执行
如下代码:
通过 javap -c LockTest.class 指令获取该类的class字节码数据如下:
如上就是这段代码段字节码指令,我们可以清晰段看到,其实synchronized映射成字节码指令就是增加来两个指令:monitorenter和monitorexit。当一条线程进行执行的遇到monitorenter指令的时候,它会去尝试获得锁,如果获得锁那么锁计数+1(因为它是一个可重入锁,所以需要用这个锁计数判断锁的情况),如果没有获得锁,那么阻塞。当它遇到monitorexit的时候,锁计数器-1,当计数器为0,那么就释放锁。
有的朋友看到这里就疑惑了,为什么有2个monitorexit呀?马上回答这个问题:synchronized锁释放有两种机制,一种就是执行完释放;另外一种就是发送异常,虚拟机释放。图中第二个monitorexit就是发生异常时执行的流程。而且,从图中我们也可以看到在第18行,有一个goto指令,也就是说如果正常运行结束会跳转到26行执行。
Lock:Lock实现和synchronized不一样,后者是一种悲观锁,它胆子很小,它很怕有人和它抢吃的,所以它每次吃东西前都把自己关起来。而Lock呢底层其实是CAS乐观锁的体现,它无所谓,别人抢了它吃的,它重新去拿吃的就好啦,所以它很乐观。具体底层怎么实现,如果面试问起,你就说底层主要靠volatile和CAS操作实现的。
尽可能去使用synchronized而不要去使用LOCK,jdk1.6~jdk1.7中对 synchronized 进行优化:
1、线程自旋和适应性自旋
Java线程其实是映射在内核之上,线程的挂起和恢复会极大的影响开销。并且jdk官方人员发现,很多线程在等待锁的时候,在很短的一段时间就获得了锁,所以它们在线程等待的时候,并不需要把线程挂起,而是让他无目的的循环,一般设置10次。这样就避免了线程切换的开销,极大的提升了性能。 而适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次一下。它可以根据它前面线程的自旋情况,从而调整它的自旋,甚至是不经过自旋而直接挂起
2、锁消除【Lock Elimination】
锁消除就是把不必要的同步在编译阶段进行移除,惊讶!我自己写的代码我会不知道这里要不要加锁?需要你教我做事?我加了锁就是表示这边会有同步呀? 并不是这样,这里所说的锁消除并不一定指代是你写的代码的锁消除,而是根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,不必要加锁,我打一个比方: 在jdk1.5以前,我们的String字符串拼接操作其实底层是StringBuffer实现,而在jdk1.5之后,是用StringBuilder来拼接。我们考虑前面的情况,比如如下代码:
底层实现会变成这样:
StringBuffer是一个线程安全的类,也就是说两个append方法都会同步,通过指针逃逸分析(就是变量不会外泄),我们发现在这段代码并不存在线程安全问题,这个时候就会把这个同步锁消除
3、锁粗化
在用synchronized的时候,我们都讲究为了避免大开销,尽量同步代码块要小。Hotspot 确实进行了锁粗化优化,可以有效合并几个相邻同步块,从而降低锁开销。能够把下面的代码
转换为:
Hotspot 能否对循环进行这种优化?例如,把
转换为
理论上,没有什么能阻止我们这样做,甚至可以把这种优化看作只针对锁的优化,像 loop unswitching 一样。然而,缺点是可能把锁优化后变得过粗,线程在执行循环时会占据所有的锁
小贴士:Loop unswitching 是一种编译器优化技术。通过复制循环主体,在 if 和 else 语句中放一份循环体代码,实现将条件句的内部循环移到循环外部,进而提高循环的并行性。由于处理器可以快速运算矢量,因此执行速度得到提升。
4、轻量级锁和偏向锁
轻量级锁和偏向锁在上边锁分类中已经解释,不再复述,JDK将 synchronized 升级成了这两种特性的锁
总结
这里对Java多线程的运行机制,线程安全问题产生的原因和解决方案,锁分类,并对 synchronized 和 Lock的底层原理进行分析。多线程是一门比较深的学问,不同的场景使用方法都不同,但是本质几乎一样,如果您对本文有什么疑问或者问题欢迎在评论区指出。
Java的多线程仅仅是开始远没有结束,比如多线程的8锁问题,JUC中的原子类,volatile关键字,ThreadLocal,分布式锁,线程通信,JDK中各个线程安全类如何实现线程安全的等等都会陆续更新出来,欢迎持续关注!