蹊源的Java笔记—线程并发与线程安全
前言
上期博客我们对线程与线程池相关知识进行了总结,实际上我们在使用线程的过程中有不得不提的问题就是线程并发与线程安全,我们在为了提升系统性能的时候,采用多线程的方式,就有可能会造成数据不安全,本篇文章蹊源将带领大家了解一下线程并发与线程安全相关的知识。
线程与线程池可参考我的博客:蹊源的Java笔记—线程与线程池
Spring知识可参考我的博客:蹊源的Java笔记—Spring
正文
线程并发
线程并发,指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干端,使多个进程快速交替的执行。
线程并发可能会造成以下两种情况:
- 线程安全问题: 多个线程同时操作共享变量,一定情况下会造成数据不准确问题。
- 共享内存不可见性问题:由于
Java
内存模型会造成一定程度内存不可见,一般使用volatile
关键字来解决。
常见的并发包
java.util.concurrent
:多线程并发包
java.util.concurrent.atomic
:多线程的原子性操作提供的工具类的包
java.util.concurrent.lock
: 多线程的锁机制的包
提供了3个接口:
-
Lock
接口:支持锁规则 -
ReadWriteLock
接口:定义了一些读取者可以共享而写入者独占的锁。 -
Condition
接口:描述了可能与锁有关联的条件变量
CountDownLatch(线程计数器)和CyclicBarrier(回环栅栏)区别:
-
CountDownLatch
和CyclicBarrier
都是在java.util.concurrent
下 -
CountDownLatch
用于主线程等待其他子线程任务都执行完毕后再执行,CyclicBarrier
用于一组线程相互等待大家都达到某个状态后,再同时执行; -
CountDownLatch
是不可重用的,CyclicBarrier
可重用
Semaphore信号量
Semaphore信号量是 java.util.concurrent
包下用来:限制线程并发的数量的工具类,基于AQS
实现的,在构造的时候会设置一个值,代表着资源数量。在请求资源调用task
时,会用自旋的方式减1,如果成功,则获取成功了,如果失败,导致资源数变为了0,就会加入队列里面去等待(这里也可以像reentrantLock
一样选择公平或者非公平的方式)。调用release
的时候会加1,补充资源,并唤醒等待队列。
线程同步
线程同步是指多线程通过线程安全的机制来控制线程之间的执行顺序也可以说是在线程之间通过同步建立起执行顺序的关系,如果没有实现线程同步的话,就会造成多线程下数据安全问题。
实现线程同步的6种方式:
- 使用
Synchronized
标记临界区 - 对变量使用
volatile
进行标记,可以实现原子操作的线程安全 - 使用锁机制
- 使用局部变量
ThreadLocal
实现线程同步 - 使用阻塞队列
SyschronousQueue
实现线程同步 - 使用原子变量实现线程同步
Java锁机制
乐观锁与悲观锁
- 悲观锁:一段执行逻辑加上悲观锁,不同线程同时执行时,只能有一个线程执行,其他的线程在入口处等待,直到锁被释放.
sychronized
提供的是悲观锁,适合写入频繁场景 - 乐观锁:一段执行逻辑加上乐观锁,不同线程同时执行时,可以同时进入执行,在最后更新数据的时候要检查这些数据是否被其他线程修改了(版本和执行初是否相同),没有修改则进行更新,否则放弃本次操作.适合读取频繁场景
如何实现乐观锁:
- 在数据库 添加
version
字段 - 通过
CAS
(自旋锁)操作:内存位置、期望原值、新值 (CAS
称为无锁栈、无锁队列 是一个原子性操作(一段内存同时只能有一个CPU
方位))update status=3 where id=111 and status=1;
(增加筛选条件)
CAS操作是一种无锁的方式来实现线程安全的方式,java.util.concurrent.atomic
原子变量就是基于CAS
操作实现的:
-
AtomicInteger
-
AtomicReference
:用于引用类 -
AtomicStampedReference
:引入时间戳用于对时间敏感的引用类 ,它可以避免ABA
问题 -
AtomicIntegerArray
:用于数组 -
AtomicIntegerFieldUpdater
:借助反射机制,让普通变量也能使用原则操作,如User
类中的age
;
CAS操作有哪些问题:
-
ABA
问题,指的是修改后又被修改回来。 - 每个线程都会尝试去执行,对
CPU
的消耗比较大 - 只能保证一个共享变量的原子操作
自旋锁
自旋锁,是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
自旋锁的状态是通过自旋锁的保持者来改变(命令模式).
举个例子:while(true) for(;;)
进入的就是一种自旋锁。
公平锁和非公平锁
公平锁和非公平锁的区别在于公平锁会保证先来的线程会先获取资源(其内部实现时AQS
原理),而非公平不能保证。公平锁的实现是通过FIFO
先进先出的队列来实现的,非公平锁会由JVM
就近安排线程获取资源的顺序,所以非公平锁的性能是由于公平锁的。
sychronized
是非公平锁,ReenterLock
(默认也是非公平锁)可以实现公平锁。
互斥锁
互斥锁: 同一时刻只能有一个线程获得互斥锁,其余线程处于睡眠状态.
自旋锁vs互斥锁
- 互斥锁和自旋锁都能够保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。
- 自旋锁没有上下文的切换性能比较高,但是存在死锁的风险,只适用保持锁时间比较短的情况下。
- 互斥锁会让获取不到资源的线程
sleep
(不放弃锁,放弃对CPU
的争夺),进入线程阻塞状态。
synchronized
和ReenterLock
都是互斥锁。
可重入锁
可重入锁:不放弃锁,已经获得该锁的线程可以再次进入被该锁锁定的代码块。
可重入的原理是通过计数器来实现的:同一个线程没进入一次,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。
synchronized
和ReenterLock
都是可重入锁。
sychronized同步锁
sychronized的作用是:用来确保多线程下的线程安全。
sychronized使用方法
- 对象锁:用
sychronized
修饰代码块 (手动去指定锁对象 入口monitorenter
出口monitorexit
) - 类锁:用
sychronized
修饰普通方法(类锁只能在同一时刻被一个对象拥有 通过方法的 ACC_SYNCHRONIZED
标志符是否被设置,这里会隐形调用monitorenter
、monitorexit
这两个指令 )
sychronized实现原理
monitorenter指令(入口):每个对象都会关联一个监视器锁(monitor
) ,当monitor
被占用时就会处于锁定状态,线程执行monitorenter
指令时尝试获取monitor
的所有权,过程如下:
- 如果
monitor
的进入数为0,则该线程进入monitor
,然后将进入数设置为1,该线程即为monitor
的所有者; - 如果线程已经占有该
monitor
,只是重新进入,则进入monitor
的进入数加1; - 如果其他线程已经占用了
monitor
,则该线程进入阻塞状态,直到monitor
的进入数为0,再重新尝试获取monitor
的所有权;
monitorexit指令(出口):执行monitorexit
的线程必须是objecter
所对应的monitor
的所有者。指令执行时,monitor
的进入数减1,如果减1后进入数为0,那线程退出monitor
,不再是这个monitor
的所有者。其他被这个monitor
阻塞的线程可以尝试去获取这个 monitor
的所有权。
sychronized锁的状态
- 无锁状态
- 偏向锁状态:第一个线程获取该锁,存储该线程
id
,并改变对象的对象头MarkWord
中的状态。 偏向锁可以撤销成无锁状态, 线程是不会主动释放偏向锁,需要等待其他线程来竞争,这种机制可以减小只有一个线程下,CAS
操作的性能消耗 - 轻量级锁状态:第二个线程尝试获取该锁,当检测到对象头
MarkWord
已存在偏向锁,进入自旋。 - 重量级锁状态:线程自旋失败,进入阻塞状态。
知识点:
- 锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
- 偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(
CAS
)的开销,看起 来让这个线程得到了偏护。 - 轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进 一步提高性能。
sychronized在jdk1.6之后的优化
- 自旋锁
- 自适应自旋锁:根据自旋状况,自动调整自旋的次数。(
jdk1.6
默认是10次,jdk1.7
之后由JVM
决定) - 锁消除:
JVM
检测到不可能存在共享数据竞争,这是JVM
会对这些同步锁进行锁消除(无锁状态) - 锁粗化:
JVM
检测到同一个对象有连续的加锁、解锁操作,会合并成为一个更大范围的加锁、解锁操作,例如将加锁解锁操作移到for
循环外面。 - 轻量级锁
- 偏向锁
- 重量级锁
知识点:
-
sychronized
锁升级到重量级锁时就是进入不可逆状态。 - 对象头中的
MarkWord
记录了对象和锁有关的信息
ReentrantLock同步锁
ReentantLock
继承接口 Lock
并实现了接口中定义的方法,它是一种可重入锁,除了能完成synchronized
所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等 避免多线程死锁的方法。
ReentrantLock
则需要用户手动去释放锁,若没有主动释放锁,就有可能导致出现死锁现,防止出现死锁的方法有:
- 响应中断
lockInterruptibly()
- 可轮询锁
tryLock()
- 定时锁
tryLock( long time)
ReentrantLock
获取锁的几个方式:
-
tryLock
能获得锁就返回 true
,不能就立即返回 false
; -
tryLock(long timeout,TimeUnit unit)
,可以增加时间限制,如果超过该时间段还没获得锁,返回 false
; -
lock
能获得锁就返回 true
,不能的话一直等待获得锁; -
lockInterruptibly
能获得锁就返回 true
,不能话一直等待获得锁,但等待的过程如果发生interrupt()
中断操作,lock
不会抛异常,lockInterruptibly
会抛异常。
ReentrantLock原理
ReentrantLock主要利用CAS+AQS
队列来实现的:
- 先通过
CAS
尝试获取锁, 如果此时已经有线程占据了锁,那就加入AQS
队列并且被挂起; - 当锁被释放之后, 公平锁的情况下:排在队首的线程会被唤醒
CAS
再次尝试获取锁,非公平锁下由JVM
就近决定哪个线程获取资源。
ReentrantLock和synchronized
相同点:
- 都是同步锁,用来确保多线程下数据安全
- 都是互斥锁、可重入锁 ,获取该锁的线程可以再次进入被该锁锁定的代码块。
不同点:
-
synchronized
是非公平锁,ReentrantLock
可以是非公平锁,也可以是公平锁。 -
ReentrantLock
是 API
级别的,synchronized
是 JVM
级别的,synchronized
的加锁解锁都是由JVM
来控制,ReentrantLock
需要自己来控制。 -
ReentrantLock
通过 Condition
可以绑定多个条件,从而实现 分组需要唤醒的线程们,可以精确唤醒,而不像synchronized
那样随便唤醒一个线程或者全部线程。 -
synchronized
不可中断,除非抛出异常或者正常运行完成; ReentrantLock
可中断, 调用interrupt()
方法可中断 -
ReentrantLock
则需要用户手动去释放锁,若没有主动释放锁,就有可能导致出现死锁现;synchronized
不需要用户手动去释放锁,当synchronized
代码执行完成后,系统会自动让线程释放对锁的占用;
ReadWriteLock读写锁
ReadWriteLock
读写锁:
- 读写锁的规则是“ 读读不互斥,读写互斥,写写互斥”,所以
ReadWriteLock
读锁相当于一个共享锁,写锁相当于互斥锁。 -
ReentrantLock
为独占锁, 每次只能有一个线程能持有锁,ReentrantLock
就是通过独占锁的方式来实现互斥的。
读写锁是基于AQS
原理
-
AQS
是将每一条请求共享资源的线程封装成一个CLH
锁队列的一个结点(Node
),来实现锁的分配。 -
AQS
就是基于CLH
队列,用volatile
修饰共享变量state
,线程通过CAS
去改变状态符,成功则获取锁成功,失败则进入等待队列,等待被唤醒。 -
CLH
队列是一个虚拟的双向队列(FIFO
先进先出),虚拟的双向队列即不存在队列实例,仅存在节点之间的关联关系。
AQS(AbstractQueuedSynchronizer)
这个抽象类,借助模板模式,提供了以下方法自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS
已经在顶层实现好了。自定义同步器实现的时候主要实现下面几种方法:
- isHeldExclusively():该线程是否正在独占资源。只有用到
condition
才需要去实现它。 - tryAcquire(int):独占方式。尝试获取资源,成功则返回
true
,失败则返回false
。 - tryRelease(int):独占方式。尝试释放资源,成功则返回
true
,失败则返回false
。 - tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回
true
,否则返回false
。
知识点:
1.为什么Java中的long与double可能存在线程安全的问题
除了long
和double
类型,Java
基本数据类型都是的简单读写都是原子的,而简单读写就是赋值和return
语句。
目前的JVM
(java
虚拟机)都是将32位作为原子操作,并非64位。当线程把主存中的 long/double
类型的值读到线程内存中时,可能是两次32位值的写操作,显而易见,如果几个线程同时操作,那么就可能会出现高低2个32位值出错的情况发生。要在线程间共享long
与double
字段是,必须在synchronized
中操作,或是声明为volatile
。
2.常见的可能会存在线程安全的情况:
-
ArrayList
不是线程安全的,Vector
是线程安全的,线程安全是基于synchronized
来实现的. -
HashMap
不是线程安全的,HashTable
是线程安全的,线程安全实现是基于synchronized
来实现的,ConcurrentHashMap
也是线程安全的,采用锁分离技术,线程安全是基于CAS+synchronized
实现的。 -
StringBuilder
不是线程安全, StringBuffer
是线程安全的,线程安全是基于synchronized
来实现的。 -
SimpleDateFormat
如果在多线程情况下使用存在线程安全问题,可以通过使用局部变量(常用)、 ThreadLocal
线程副本变量、同步锁等方式来解决。