前言
主要内容为多线程中的各种锁,简单的使用和进行区分。
一、为什么要有锁?
1.1 线程安全
当多个线程同时操作同一个共享全局变量的时候,就容易出现线程安全问题,线程安全问题只会影响到线程对同一个共享的全局变量的写操作。
1.1.1 Java内存模型
Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,
线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。
线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。
不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
1.1.1 线程操作对象的工作机制
1:从主存复制变量到当前工作内存
2:执行代码,改变共享变量的值
3:用工作内存数据刷新主存相关内容
如果多个线程同时执行,由于执行的时序不一样,就会导致主存的值被多个线程修改,会导致安全问题
1.1.2 线程安全出现问题的条件
1.是否是多线程环境
2.是否有共享数据
3.是否有多条语句操作共享数据
1.2 锁的作用
**锁:**解决资源占用的问题;保证同一时间一个对象只有一个线程在访问;保证数据的安全性。
1.3 锁的范围
对象锁
在java中每个对象都有一个唯一的锁,对象锁用于对象实例方法或者一个对象实例上面的。
同步函数(public synchronized void buyTicket())所用的是this锁;
类锁
类锁:是用于一个类静态方法或者class对象的,一个类的实例对象可以有多个,但是只有一个class对象。
静态同步函数(public static synchronized void buyTicket())所持有的是当前类加载的时候的字节码文件对象(即上述代码的ThreadProblem.class)
二、synchronized 的使用
2.1 概述
为了解决线程安全的问题,使用了synchronized 进行加锁的操作。
同步机制synchronized:synchronized关键字用于修饰方法或者单独的synchronized代码块,当一个线程想执行synchronized中的内容时,必须先获取到对象锁,当对象锁没有线程占用时,进入synchronized方法会自动获取到对象锁,执行完毕后会自动释放锁,如果对象锁被A线程占用,B线程想执行synchronized的代码只能等待A线程执行完毕后(只能有一个线程操作一个对象),释放对象锁,B线程才能获取到对象锁进入方法执行。一个线程获得对象A的锁,也可以获得对象B的锁,两个不同类的对象锁没有关联。
2.2 方法锁(synchronized修饰方法时)
通过在方法声明中加入 synchronized关键字来声明 synchronized 方法。
synchronized 方法控制对类成员变量的访问:
每个类实例对应一把锁,每个 synchronized 方法都必须获得调用该方法的类实例的锁方能执行,否则所属线程阻塞,方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,此后被阻塞的线程方能获得该锁,重新进入可执行状态。这种机制确保了同一时刻对于每一个类实例,其所有声明为 synchronized 的成员函数中至多只有一个处于可执行状态,从而有效避免了类成员变量的访问冲突。
2.3 对象锁(synchronized修饰方法或代码块)
当一个对象中有synchronized method或synchronized block的时候调用此对象的同步方法或进入其同步区域时,就必须先获得对象锁。如果此对象的对象锁已被其他调用者占用,则需要等待此锁被释放。(方法锁也是对象锁)
java的所有对象都含有1个互斥锁,这个锁由JVM自动获取和释放。线程进入synchronized方法的时候获取该对象的锁,当然如果已经有线程获取了这个对象的锁,那么当前线程会等待;synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁。这里也体现了用synchronized来加锁的1个好处,方法抛异常的时候,锁仍然可以由JVM来自动释放。
public class Test
{
// 对象锁:形式1(方法锁)
public synchronized void Method1()
{
System.out.println("我是对象锁也是方法锁");
try
{
Thread.sleep(500);
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
// 对象锁:形式2(代码块形式)
public void Method2()
{
synchronized (this)
{
System.out.println("我是对象锁");
try
{
Thread.sleep(500);
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
}
2.4 类锁(synchronized 修饰静态的方法或代码块)
由于一个class不论被实例化多少次,其中的静态方法和静态变量在内存中都只有一份。所以,一旦一个静态的方法被申明为synchronized。此类所有的实例化对象在调用此方法,共用同一把锁,我们称之为类锁。
对象锁是用来控制实例方法之间的同步,类锁是用来控制静态方法(或静态变量互斥体)之间的同步。
java类可能会有很多个对象,但是只有1个Class对象,也就是说类的不同实例之间共享该类的Class对象。Class对象其实也仅仅是1个java对象,只不过有点特殊而已。由于每个java对象都有1个互斥锁,而类的静态方法是需要Class对象。所以所谓的类锁,不过是Class对象的锁而已。获取类的Class对象有好几种,最简单的就是[类名.class]的方式。
public class Test
{
// 类锁:形式1
public static synchronized void Method1()
{
System.out.println("我是类锁一号");
try
{
Thread.sleep(500);
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
// 类锁:形式2
public void Method2()
{
synchronized (Test.class)
{
System.out.println("我是类锁二号");
try
{
Thread.sleep(500);
} catch (InterruptedException e)
{
e.printStackTrace();
}
}
}
}
2.5 局限性
Synchronized它锁定一个对象,可以加锁到方法上,亦可加锁到一段代码上。它加锁到一段代码上,代表了对这段代码中的共享对象/资源进行单独运行;Lock通过方法lock(),unlock()来进行锁定。
Synchronized有局限性:
-
**它不可中断,一旦开始执行,一定要等执行结束后才能释放。**对于正在申请锁的行为,只能死等。
-
不能向tryLock()一样设定超时时间。
-
只有一个条件,不能像Condition那样可以设置多个。
Synchronize是可重入锁(Reentrance Lock),不可中断;
Lock接口有一个ReentranceLock的实现类,可以实现Synchronized的功能,但更加灵活,在极端情况下性能会更好一些。
三、ReentrantLock
3.1 概述
可重入锁,顾名思义,这个锁可以被线程多次重复进入进行获取操作。
ReentantLock继承接口Lock并实现了接口中定义的方法,除了能完成synchronized所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。(功能多)
Lock实现的机理依赖于特殊的CPU指定,可以认为不受JVM的约束,并可以通过其他语言平台来完成底层的实现。在并发量较小的多线程应用程序中,ReentrantLock与synchronized性能相差无几,但在高并发量的条件下,synchronized性能会迅速下降几十倍,而ReentrantLock的性能却能依然维持一个水准。
需要手动解锁
建议在高并发量情况下使用ReentrantLock。
3.2 简单使用
Lock通过方法lock(),unlock()来进行锁定。
Lock lock = new ReentrantLock();
try {
lock.lock();
//…进行任务操作5 }
finally {
lock.unlock();
}
如果可能有异常出现,那么必须把 unlock() 方法放置在finally中。
四、锁的分类
4.1 公平锁与非公平锁
从其它等待中的线程是否按顺序获取锁的角度划分
我先做个形象比喻,比如现在有一个餐厅,一次最多只允许一个持有钥匙的人进入用餐,那么其他没拿到钥匙的人就要在门口等着,等里面那个人吃完了,他出来他把钥匙扔地上,后边拿到钥匙的人才能进入餐厅用餐。
- 公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的先后顺序来一次获得锁。所用公平锁就好像在餐厅的门口安装了一个排队的护栏,谁先来的谁就站的靠前,无法进行插队,当餐厅中的人用餐结束后会把钥匙交给排在最前边的那个人,以此类推。公平锁的好处是,可以保证每个排队的人都有饭吃,先到先吃后到后吃。但是弊端是,要额外安装排队装置。
- 非公平锁:理解了公平锁,非公平锁就很好理解了,它无非就是不用排队,当餐厅里的人出来后将钥匙往地上一扔,谁抢到算谁的。但是这样就造成了一个问题,那些身强体壮的人可能总是会先抢到钥匙,而那些身体瘦小的人可能一直抢不到,这就有可能将一直抢不到钥匙,最后导致需要很长时间才能拿到钥匙甚至一直拿不到直至饿死。
4.1.1公平锁与非公平锁的总结:
(1) 公平锁的好处是等待锁的线程不会饿死,但是整体效率相对低一些;非公平锁的好处是整体效率相对高一些,但是有些线程可能会饿死或者说很早就在等待锁,但要等很久才会获得锁。其中的原因是公平锁是严格按照请求所的顺序来排队获得锁的,而非公平锁时可以抢占的,即如果在某个时刻有线程需要获取锁,而这个时候刚好锁可用,那么这个线程会直接抢占,而这时阻塞在等待队列的线程则不会被唤醒。
(2) 在 java 中,公平锁可以通过 new ReentrantLock (true) 来实现;非公平锁可以通过 new ReentrantLock (false) 或者默认构造函数 new ReentrantLock () 实现。
(3)synchronized 是非公平锁,并且它无法实现公平锁。
4.2 互斥锁
从能否有多个线程持有同一把锁的角度划分
互斥锁的概念非常简单,也就是我们常说的同步,即一次最多只能有一个线程持有的锁,当一个线程持有该锁的时候其它线程无法进入上锁的区域。在 Java 中 synchronized 就是互斥锁,从宏观概念来讲,互斥锁就是通过悲观锁的理念引出来的,而非互斥锁则是通过乐观锁的概念引申的。
4.3 重入锁(递归锁)与 不可重入锁(自旋锁)
**从一个线程能否递归获取自己的锁的角度划分 **
我们知道,一条线程若想进入一个被上锁的区域,首先要判断这个区域的锁是否已经被某条线程所持有。如果锁正在被持有那么线程将等待锁的释放,但是这就引发了一个问题,我们来看这样一段简单的代码:
public class ReentrantDemo {
private Lock mLock;
public ReentrantDemo(Lock mLock) {
this.mLock = mLock;
}
public void outer() {
mLock.lock();
inner();
mLock.unlock();
}
public void inner() {
mLock.lock();
// do something
mLock.unlock();
}
}
当线程 A 调用 outer () 方法的时候,会进入使用传进来 mlock 实例来进行 mlock.lock () 加锁,此时 outer () 方法中的这片区域的锁 mlock 就被线程 A 持有了,当线程 B 想要调用 outer () 方法时会先判断,发现这个 mlock 这把锁被其它线程持有了,因此进入等待状态。我们现在不考虑线程 B,单说线程 A,线程 A 进入 outer () 方法后,它还要调用 inner () 方法,并且 inner () 方法中使用的也是 mlock () 这把锁,于是接下来有趣的事情就来了。按正常步骤来说,线程 A 先判断 mlock 这把锁是否已经被持有了,判断后发现这把锁确实被持有了,但是可笑的是,是 A 自己持有的。那你说 A 能否在加了 mlock 锁的 outer () 方法中调用加了 mlock 锁的 inner 方法呢?答案是如果我们使用的是可重入锁,那么递归调用自己持有的那把锁的时候,是允许进入的。
- 可重入锁:可以再次进入方法 A,就是说在释放锁前此线程可以再次进入方法 A(方法 A 递归)。
- 不可重入锁(自旋锁):不可以再次进入方法 A,也就是说获得锁进入方法 A 是此线程在释放锁前唯一的一次进入方法 A。
下面这段代码演示了不可重入锁:
public class Lock{
private boolean isLocked = false;
public synchronized void lock()
throws InterruptedException{
while(isLocked){
wait();
}
isLocked = true;
}
public synchronized void unlock(){
isLocked = false;
notify();
}
public void test1(){
synchronized(this){
synchronized(this){
}
}
}
}
可以看到,当 isLocked 被设置为 true 后,在线程调用 unlock () 解锁之前不管线程是否已经获得锁,都只能 wait ()。
4.4 悲观锁与乐观锁
悲观锁
悲观锁是就是悲观思想,即认为读少写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。java 中的悲观锁就是 Synchronized,AQS 框架下的锁则是先尝试 cas 乐观锁去获取锁,获取不到,才会转换为悲观锁,如 RetreenLock。
乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读 - 比较 - 写的操作。CAS
4.5 共享锁、排它锁
共享锁和排它锁多用于数据库中的事物操作,主要针对读和写的操作。而在 Java 中,对这组概念通过 ReentrantReadWriteLock 进行了实现,它的理念和数据库中共享锁与排它锁的理念几乎一致,**即一条线程进行读的时候,允许其他线程进入上锁的区域中进行读操作;当一条线程进行写操作的时候,不允许其他线程进入进行任何操作。**即读 + 读可以存在,读 + 写、写 + 写均不允许存在
共享锁:也称读锁或 S 锁。如果事务 T 对数据 A 加上共享锁后,则其他事务只能对 A 再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。
排它锁:也称独占锁、写锁或 X 锁。如果事务 T 对数据 A 加上排它锁后,则其他事务不能再对 A 加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。
五、多线程的原子操作
5.1 概述
何谓原子性操作,即为最小的操作单元,比如i=1,就是一个原子性操作,这个过程只涉及一个赋值操作。又如i++就不是一个原子操作,它相当于语句i=i+1;这里包括读取i,i+1,结果写入内存三个操作单元。因此如果操作不符合原子性操作,那么整个语句的执行就会出现混乱,导致出现错误的结果,从而导致线程安全问题。
因此,在多线程中需要保证线程安全问题,就应该保证操作的原子性,那么如何保证操作的原子性呢?其一当然是加锁,这可以保证线程的原子性,比如使用synchronized代码块保证线程的同步,从而保证多线程的原子性。但是加锁的话,就会使开销比较大。另外,可以使用J.U.C下的atomic来实现原子操作。
5.2 CAS
5.2.1 原理与问题
CAS操作(又称为无锁操作)是一种乐观锁策略。它假设所有线程访问共享资源的时候不会出现冲突,因此不会阻塞其他线程的操作。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
5.2.2 操作过程
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
5.2.3 ABA问题
ABA问题。因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C,或使用AtomicStampedReference工具类。
5.3 Atomic包的使用
5.3.1 Atomic包中原子更新基本类型的工具类
AtomicBoolean:以原子更新的方式更新boolean;
AtomicInteger:以原子更新的方式更新Integer;
AtomicLong:以原子更新的方式更新Long;
5.3.2 AtomicInteger为例总结常用的方法
- addAndGet(int delta):以原子方式将输入的数值与实例中原本的值相加,并返回最后的结果;
- incrementAndGet() :以原子的方式将实例中的原值进行加1操作,并返回最终相加后的结果;
- getAndSet(int newValue):将实例中的值更新为新值,并返回旧值;
- getAndIncrement():以原子的方式将实例中的原值加1,返回的是自增前的旧值;****
参考资料
java多线程学习7-线程安全问题 - u014574478的专栏 - CSDN博客
浅谈多线程之锁的机制 - z1035075390的博客 - CSDN博客
(4 封私信 / 25 条消息) 多线程的锁机制 - 搜索结果 - 知乎