一.锁
1.什么是锁?
锁(Lock)是一种用于实现多线程并发控制的机制。在多线程并发执行时,多个线程可能会同时访问或修改共享资源,导致数据不一致和程序出错。为了解决这些问题,需要使用锁机制来协调多个线程对共享资源的访问。
锁提供了两种状态:锁定和未锁定。当一个线程申请获得锁时,如果该锁处于未锁定状态,就将该锁设置为锁定状态,然后允许线程访问临界区。如果该锁已被其他线程持有,则线程将被阻塞,直到该锁被释放后再次尝试获取锁。
Java 提供了两种类型的锁:内置锁和显式锁。
内置锁是通过 synchronized 关键字来实现的,在 Java 中,每个对象都有一个内置锁。当一个线程执行 synchronized 代码块时,它必须先获得该对象的内置锁。如果该内置锁已经被其他线程持有,则当前线程将被阻塞,直到该锁被释放后再次尝试获取锁。
显式锁是通过 Lock 接口来实现的,它提供了更加灵活和高级的锁机制。与内置锁不同,显式锁可以通过 lock() 和 unlock() 方法
Lock 需要显式地获取和释放锁,并且在获取锁的过程中需要占用一定的资源。这主要是因为 Lock 接口的实现通常会涉及到更多的操作,例如使用 CAS(Compare and Swap)等底层原子操作来保证线程安全。
在获取锁的过程中,通常需要进行一些额外的操作,如自旋、阻塞等待其他线程释放锁。这些操作都会消耗一定的 CPU 资源和内存资源。
当线程获取到锁后,它将立即执行临界区代码。所谓临界区指的是访问共享资源的代码块。在多线程并发访问共享资源时,为了保证数据的完整性和一致性,需要使用锁机制来协调多个线程的访问,从而避免出现竞态条件(Race Condition)等问题。
当一个线程成功获取到锁时,它就可以进入临界区执行共享资源的访问和修改操作。其他线程在获取不到该锁时,将处于阻塞状态,等待该锁被释放后再次尝试。
需要注意的是,一旦线程获取到锁,它不会一直执行临界区代码。线程在完成对共享资源的访问和修改后,必须释放该锁,以便其他线程能够继续访问共享资源。因此,在使用锁机制时,需要特别关注锁的使用范围和锁的释放时机,以免出现死锁和竞争条件等问题。
另外,锁机制也不是万能的解决方案,在处理多线程共享资源时,还需要结合其他技术和方法,如使用无锁数据结构、CAS 原子操作、线程安全的容器或集合等。
二.什么样的线程比较容易获取到锁?
(1)优先级高:线程的优先级越高,获取锁的机会也就越多。这并不意味着优先级高的线程一定会胜出,因为这还涉及到操作系统调度策略和具体场景等因素。
(2)执行时间短:在锁竞争中,执行时间短的线程会更容易获得锁。因为,执行时间长的线程会占用锁的时间较长,导致其他等待锁的线程无法及时获取锁,从而降低程序的效率和性能。
(3)不阻塞:在多线程编程中,如果一个线程阻塞时间过长,那么它就会成为其他线程的瓶颈,影响程序的整体性能。因此,在锁竞争中,不会阻塞的线程更容易胜出。
(4)合理使用锁:在使用锁机制时,要避免过度竞争和死锁等问题。合理使用锁,控制锁的粒度,尽量减少等待时间,可以提高线程竞争的胜率。
综上所述,能在锁竞争中胜出的线程需要具备多方面的优势,包括优先级高、执行时间短、不阻塞等特点,并且需要合理使用锁,避免出现竞争和死锁等问题。
三.synchronized
1.认识对象头:synchronized用的锁存在Java对象头当中
2.synchronized的用法
(1)修饰方法(普通方法,静态方法)普通方法实际上加到了this上,静态方法加到了类对象上
(2)修饰代码块 手动指定加到那个对象上
明确锁对象针对那个对象加锁,如果两个线程针对同一个对象加锁,就会出现锁竞争,一个线程先能获取到锁,另一个线程阻塞等待,等待上一个线程解锁,它才能获取锁成功
如果两个线程针对不同对象加锁,就不会产生锁竞争,这两个线程都能获取到各自的锁
如果两个线程,一个线程加锁,另一个线程不加锁,这个时候不会有锁竞争
synchronized修饰方法
class flg
{
public static int m=0;
public synchronized void add1()
{
m++;
}
}
synchronized修饰代码块
class flg
{
public static int m=0;
public void add2()
{
synchronized (this)
{
m++;
}
}
}
这两种写法本质上是一样的。
一个加锁另一个不加锁的情况
class flg
{
public static int m=0;
public void add2()
{
synchronized (this)
{
m++;
}
}
public void add()
{
m++;
}
}
当两个线程分别去调用这两个方法是,实际上相当于没加锁
synchronized的力量是jvm提供的,jvm的力量是操作系统提供的,操作系统的力量是CPU提供的,从根本上说,是CPU提供了这样的指令才能让操作系统的API提供给JVM,JVM提供给synchronizd
3. synchronized怎样保证可见性?
(1)线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值
(2)线程加锁后,其它线程无法获取主内存中的共享变量
(3)线程解锁前,必须把共享变量的最新值刷新到主内存中
synchronized怎样保证有序性?
synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排
synchronized怎么实现可重入锁?
synchronized锁对象有个计数器,当一个线程请求成功后,JVM会记下持有锁的线程,并将计数器记为1,此时其他线程请求该锁,则必须等待,而持有该线程锁的线程再次请求这个加锁的时候,计数器就会再加1
4.锁膨胀机制:
synchronized内部有一些优化机制,存在的目的是让这个锁更高效,实用。
-
锁升级/锁膨胀
(1)无锁
(2)偏向锁
(3)轻量级锁
(4)重量级锁
synchronized(locker)
{
}
当程序运行起来时,jvm会默认延时4s自动开启偏向锁,当代码执行到synchronized,会加偏向锁,将线程id写入当前锁对象的对象头,当执行完synchronized代码块,并不会立即释放掉偏向锁,当一个线程尝试加锁时,会先判断这个存在对象头里线程id是否是这个线程的,如果是就不用再加锁了,如果不是,就会从偏向锁,升级为轻量级锁,此时当synchronized相当于是通过自旋的方式,来进行加锁的,如果要是很快别人就释放锁了,自旋是划算的,但是如果迟迟拿不到锁,一直自旋,并不划算,synchronized自旋并不是一直的自旋,自旋到一定程度之后,就会升级到重量级锁(挂起等待锁),挂起等待锁则是基于操作系统原生的API来进行加锁,linux原生提供了mutex一组API,操作系统内核提供的加锁功能,这个锁会影响到线程的调度,此时如果线程试图进行重量级加锁,并且发生锁竞争,此时线程会被放到阻塞队列中,暂时不参与CPU调度,直到锁被释放了,这个线程才有机会被调度到,并且有机会获取到锁。
不同的锁状态对应不同的锁标志位。
-
锁消除:
编译器智能的判定,看当前的代码是否需要真正的加锁,如果这个场景不需要加锁,但是程序员加了,就会自动的把锁干掉。
-
锁粗化:
锁的粒度:synchronized包含的代码越多,粒度就越粗,包含的代码越少,粒度就越细,通常情况下认为锁的粒度细一点好,但是有一些情况,锁的粒度粗一些更好
5.synchronzied底层原理
- _count:记录该线程获取锁的次数(也就是前前后后,这个线程一共获取此锁多少次)。
- _recursions:锁的重入次数。
- _owner:The Owner 拥有者,是持有该 ObjectMonitor(监视器)对象的线程;
- _EntryList:EntryList 监控集合,存放的是处于阻塞状态的线程队列,在多线程下,竞争失败的线程会进入 EntryList 队列。
- _WaitSet:WaitSet 待授权集合,存放的是处于 wait 状态的线程队列,当线程执行了 wait() 方法之后,会进入 WaitSet 队列。
- 线程通过 CAS(对比并替换)尝试获取锁,如果获取成功,就将 _owner 字段设置为当前线程,说明当前线程已经持有锁,并将 _recursions 重入次数的属性 +1。如果获取失败则先通过自旋 CAS 尝试获取锁,如果还是失败则将当前线程放入到 EntryList 监控队列(阻塞)。
- 当拥有锁的线程执行了 wait 方法之后,线程释放锁,将 owner 变量恢复为 null 状态,同时将该线程放入 WaitSet 待授权队列中等待被唤醒。
- 当调用 notify 方法时,随机唤醒 WaitSet 队列中的某一个线程,当调用 notifyAll 时唤醒所有的 WaitSet 中的线程尝试获取锁。
- 线程执行完释放了锁之后,会唤醒 EntryList 中的所有线程尝试获取锁。
三.可重入锁
一个线程针对同一个对象连续连续加锁两次,如果没问题,就叫可重入锁,如果有问题就叫不可重入锁
可重入锁(Reentrant Lock)是一种特殊的锁,可以允许一个线程多次获得同一个锁。在 Java 中,ReentrantLock 是实现可重入锁的一种方式。
当一个线程持有一个可重入锁时,它可以再次获取该锁而不会被阻塞。也就是说,可重入锁允许线程在递归时反复地获得该锁,而不会导致死锁或其它类似问题。
可重入锁的实现通常需要维护一些状态信息,比如:当前持有锁的线程、当前线程已经获取该锁的次数等。并且还需要提供 lock() 和 unlock() 等方法来实现对锁的获取和释放。
与内置锁相比,可重入锁具有更高的灵活性和可控性。可以通过 tryLock() 方法来尝试获取锁而不被阻塞,并且支持公平锁和非公平锁等多种锁类型。此外,可重入锁还提供了 Condition 类来实现线程之间的协作和通信,从而更加方便地实现高级线程同步需求。
总之,可重入锁是一种特殊的锁机制,允许线程多次获得同一个锁,并且提供更高的灵活性和可控性,能够有效避免多线程并发访问可能引起的死锁、饥饿等问题。
我们来看一段代码
public synchronized void add2()
{
synchronized (this)
{
a++;
}
}
锁对象是this,只要有线程调用add,进入add方法的时候就会先加锁(能够加锁成功),紧接着又遇到了代码块,再次尝试加锁,这两个线程是同一个线程,如果允许这个操作,这个锁是可重入的,如果不允许这个操作(第二次加锁会阻塞等待),就是不可重入的,这个情况会导致死锁
java把synchronized设定成可重入的了。
四.java标准库中的线程安全类
如果多个线程操作同一个集合类,就要考虑到线程安全的问题
Arraylist 、Linkedlist 、HashMap 、TreeMap、HashSet、TreeSet、StringBuilder 这些类在多线程代码中要格外注意
Vector、HashTable、ConcurrentHashMap、StrringBuffer 已经内置synchronized加锁,相对来说更安全一点
五.死锁
1.什么是死锁?死锁是指在多线程或者分布式系统中,两个或多个线程或进程因互相等待对方释放资源而陷入一种无法继续执行的状态,这种状态称为死锁。
具体来说,当两个或多个线程或进程同时持有自己的锁,并且又试图获取对方持有的锁时,在没有外部干涉的情况下就会发生死锁。此时,这些线程或进程会相互等待,无法继续执行,形成了一个死循环,导致整个系统无法继续运作。
例如,线程 A 持有锁 1,尝试获取锁 2;线程 B 持有锁 2,尝试获取锁 1。由于两个线程都持有自己的锁,又试图获取对方持有的锁,因此它们无法继续运行,产生了死锁。
死锁是多线程或分布式系统中最常见、也是最棘手的问题之一。它会导致系统完全停滞,影响系统的可靠性和稳定性,使得CPU资源得不到充分利用,从而影响系统的性能。
为了避免死锁的发生,程序设计中需要合理地使用锁,并对锁的使用顺序和释放进行合理的规划,同时也要避免在持有锁时执行耗时的操作,尽量缩短锁的持有时间。此外,还可以使用一些死锁检测和预防算法,如银行家算法、资源分配图法等,来减少或避免死锁的发生。
2.死锁的情况
(1)一个线程,一把锁,连续加锁两次,如果锁是不可重入锁,就会死锁
(2)两个线程两把锁,t1和t2各自针对锁A和锁B加锁,再次尝试获取对方的锁
举个例子小明有一个羽毛球,它对这个羽毛球加锁了,小张有一双羽毛球拍,并对它加锁,小明说小张把你的羽毛球拍借给我用几天,而小张对小明说,你把你的羽毛球借我用几天,这不就僵住了吗?
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
synchronized (qiu)
{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (pai)
{
System.out.println("小明把球和球拍都拿到了");
}
}
}
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
synchronized (pai)
{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (qiu)
{
System.out.println("小张把球和球拍都拿到了");
}
}
}
});
我们执行这个程序的时候,会发现程序僵住了。
我们借助jconsole这样的工具来进行定位,看线程的状态和调用栈,分析代码在哪里死锁。
我们看到线程1此时是阻塞状态,而阻塞发生在17行
我们再来看一下线程2
此时线程2也是阻塞状态,阻塞发生在38行
这也就反映了当两个线程分对锁A、锁B加锁时,再尝试获取对方的锁时,会死锁。
(3)多个线程多把锁
像这里有5个人桌子上是5双筷子,每个人都拿左边的筷子,并对其加锁,我们会发现那个人都吃不了饭,每个人都只有一双筷子。
4.死锁的必要条件:
(1)互斥使用:
线程1拿到锁之后,线程2就得等着。
(2)不可占用:
线程1拿到锁之后,必须是线程1主动释放,不能说线程2给强行获取到。
(3)请求和保持:
线程1拿到锁A之后,再尝试获取锁B,A这把锁还是保持的(不会因为获取锁B,就把A给释放了)
(4)循环等待:
线程1尝试获取到锁A和锁B 线程2尝试获取到锁B和锁A.线程1在获取B的时候等待线程2释放B,线程2在获取A的时候等待线程1释放A.
前三个条件是锁的基本特性,循环等待是这四个条件里唯一一个和代码结构相关的,也是我们可以控制的。
5.死锁解决方案
如何打破死锁呢?突破口就是循环等待
办法:给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,让线程遵守这个顺序,此时循环等待自然破除。
我们给筷子编了个号,然后让每个人都拿两边小的那一个, 然后我们发现e此时就可以拿到1和5这个筷子,那么它就可以吃饭了,当它吃完的时候,1和5放下,此时d也可以拿起5、4吃饭了,然后依次类推,每个人都能吃上饭,这样就破除了死锁。
明白了如何破除死锁我们再来看一下球和球拍那个代码,如果此时我们给这两个线程加锁固定个顺序,先加qiu,再加pai
public static void main(String[] args) {
Object qiu=new Object();
Object pai=new Object();
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
synchronized (qiu)
{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (pai)
{
System.out.println("小明把球和球拍都拿到了");
}
}
}
});
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
synchronized (qiu)
{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (pai)
{
System.out.println("小张把球和球拍都拿到了");
}
}
}
});
thread1.start();
thread2.start();
}
我们看一下运行结果:
死锁的情况也就破除了。
当多个线程互相占用资源时,如果它们按照同样的顺序去申请和释放资源,那么就可以消除死锁的发生。例如,假设有两个线程 A 和 B 竞争资源 R1 和 R2,如果 A 先申请 R1,再申请 R2,而 B 则先申请 R2,再申请 R1,那么就可能会出现死锁。但是,如果规定所有线程都必须按照相同的顺序先申请 R1,再申请 R2,那么就能够避免死锁的发生。
这种按照一定顺序申请和释放资源的方式被称为资源有序分配法(Banker's Algorithm),可以有效地避免死锁问题的发生。但是,需要注意的是,资源有序分配法并没有完全消除死锁的风险,只是减少了死锁的概率。在实际应用中,需要对资源的竞争和占用进行合理的管理和规划,以避免死锁等问题的发生。
六.内存可见性问题:
这个情况就是内存可见性问题,这也是一个线程不安全问题,一个线程读,一个线程改。
这里使用汇编来理解,大概就是这两操作,1.load ,把内存中flag的值,读取到寄存器里 2.cmp 把寄存器中的值,和0进行比较,根据比较结果,决定下一步往那个地方执行。由于load执行速度太慢(相当于cmp)来说,再加上反复load的结果都一样,编译器进行了优化,不再真正的重复load了,判定好像没有人改flag值,干脆只读取一次就好。
(1)内存可见性问题:
一个线程针对一个变量进行读取操作,同时另一个线程对这个变量进行修改,此时读取到的值,不一定是修改之后的值,这个读线程没有感知到变量的变化,归根到底是编译器/jvm在多线程环境下优化时产生了误判。
此时我们给flag这个变量加上volatile关键字,意思就是告诉编译器,这个变量是"易变的",每次都要重新读取这个变量的内存内容,不要就行优化。
上述所说的内存可见性 编译器优化问题,也不是始终会出现的(编译器可能存在误判,但也不是100%就误判)
class MyCounter{
public volatile int flag=0;
}
public class Mycount {
public static void main(String[] args) {
MyCounter myCounter=new MyCounter();
Thread t1=new Thread(() ->{
while(myCounter.flag==0)
{
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("t1循环结束");
});
Thread t2=new Thread(() ->{
Scanner scanner =new Scanner(System.in);
System.out.println("请输入一个整数");
myCounter.flag=scanner.nextInt();
});
t1.start();
t2.start();
}
}
像这段代码,如果我们在while循环里面加上个sleep,此时编译器就没有优化,while循环会终止,
(2从JMM角度重新表述内存可见性问题: java程序里有主内存,每个线程还有自己的工作内存,t1线程进行读取的时候,只是读取了工作内存的值,t2线程进行修改的时候,先修改工作内存的值,然后把工作内存的内容同步到主内存中,但是由于编译器优化,导致t1没有重新从主内存同步数据到工作内存,读到的结果就是修改之前的结果,如果把主内存代替成咱们说的"内存" 把工作内存代替成”CPU"寄存器,工作内存,不只含有CPU的寄存器,还可能有CPU的缓存cache。
CPU读取寄存器,速度比读取内存快很多,因此会在CPU内部引入缓存cache 寄存器存储空间小,读写速度快,但是价格贵,中间搞了个cache,存储空间居中,读写速度居中,成本居中,内存存储空间大,读写速度慢,便宜。当CPU要读取一个内存数据时,可能直接读取内存,也可能是读cache,还可能是读取寄存器。
volatile不保证原子性,原子性是靠synchronized来保证的,synchronized和volatile都能保证线程安全,但是不能使用volatile处理两个线程并发++这样的问题。
volatile底层是如何解决内存可见性和指令重排序的问题的?
volatile底层是通过内存屏障来实现的,在有内存屏障的地方,会禁止指令重排序,即屏障下面的代码不能跟屏障上面的代码交换执行顺序。在有内存屏障的地方,线程修改完共享变量以后会马上把该变量从本地内存写回到主内存,并且让其他线程本地内存中该变量副本失效(使用 MESI 协议)。MESI(Modified-Exclusive-Shared-Invalid)协议是其中一种常见的缓存一致性协议。
MESI(Modified-Exclusive-Shared-Invalid)协议是一种常见的缓存一致性协议,用于保持多个处理器或核心之间的缓存数据一致。下面是MESI协议的基本使用流程:
-
初始状态:
- M(Modified):缓存行中的数据已经被修改,是该处理器(或核心)独占的。
- E(Exclusive):缓存行中的数据是干净的,只有该处理器(或核心)缓存中有副本。
- S(Shared):缓存行中的数据是干净的,可能有其他处理器(或核心)也缓存了该数据。
- I(Invalid):缓存行中的数据无效,不含有效数据。
-
数据读取:
- 当一个处理器(或核心)需要读取一个共享变量时,它首先检查自己的缓存中是否有该变量的副本。
- 如果该处理器(或核心)的缓存状态是E或S,则直接从缓存中读取数据。
- 如果该处理器(或核心)的缓存状态是M,则直接从缓存中读取数据。
- 如果该处理器(或核心)的缓存状态是I,则发起一个读取请求。
-
数据写入:
- 当一个处理器(或核心)需要写入一个共享变量时,它首先检查自己的缓存中是否有该变量的副本。
- 如果该处理器(或核心)的缓存状态是E,则直接修改缓存中的数据。
- 如果该处理器(或核心)的缓存状态是M,则直接修改缓存中的数据。
- 如果该处理器(或核心)的缓存状态是S,则将缓存状态变为M,并修改缓存中的数据。
- 如果该处理器(或核心)的缓存状态是I,则发起一个写入请求。
-
数据一致性维护:
- 当一个处理器(或核心)修改共享变量时,它需要将其缓存状态标记为M,并将数据修改为新值。
- 处理器(或核心)在执行完写入操作后,会通知其他处理器(或核心)该共享变量的副本已经无效。
- 当其他处理器(或核心)接收到失效信号后,它们会将自己的缓存状态标记为I,并清除对应的缓存行。
通过以上的步骤,MESI协议可以保证多个处理器(或核心)之间的缓存数据一致性。当一个处理器(或核心)修改共享数据时,它会采用独占或互斥的方式确保数据的一致性,并及时通知其他处理器(或核心)使它们的缓存副本失效,从而保证数据的正确性和一致性。
下面是具体的步骤:
-
写回到主内存:当线程修改完共享变量后,会将该变量的新值从线程的本地内存写回到主内存。
-
发送失效信号:线程会发送失效信号(Invalidation Signal)给其他线程,通知它们该共享变量的副本已经无效。
-
缓存一致性协议操作:根据MESI协议的规则,其他线程接收到失效信号后,会执行相应的操作,使自己的本地缓存中该变量的副本失效。具体操作包括将该变量的状态标记为无效(Invalid),并清除对应的缓存行。
-
读取主内存最新值:当其他线程需要读取该共享变量时,根据缓存一致性协议的规则,它们会首先从主内存中获取最新的值。
通过这样的机制,保证了线程对共享变量的修改在主内存中可见,而不仅仅停留在线程的本地内存中。同时,其他线程也能够感知到该共享变量的变化,并获取最新的值。
我们先来看一下是具体如何解决内存可见性问题的
我们先读取主内存中flag的值,然后把他加载到线程1的工作内存,然后我们线程1使用这个变量,同 样读取主内存中flag的值,然后把他加载到线程2的工作内存,线程2把这个变量进行了修改flag=1,然后重写把它写回到主内存,这时总线嗅探机制感知到flag值已经变了,就将工作内存中
flag的缓存行置为valid(无效)(使用 MESI 协议)()反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效)此时我们的线程1就重新从主内存中读取值了。
我们再来看一下是如何解决指令重排序的问题的
提供内存屏障功能,在两个指令间添加lock指令,使CPU能够识别前后两者指令间不能重排序。
指令重排序问题需要遵循的原则:
七.wait 和notify
线程最大的问题就是抢占式执行,随机调度,于是我们发明了一些东西来控制线程之间的执行顺序,虽然线程在内核里的调度是随机的,但是可以通过一些api,让线程主动阻塞,主动放弃CPU,比如t1t2俩线程,希望t1先干活,干的差不多了,再让t2来干活,就可以让t2先wait(阻塞,主动放cpu),等t1干的差不多了,再通过notify通知t2,把t2唤醒,让t2接着干。
上述场景,使用join或者sleep行不行呢?
使用join,则必须让t1彻底执行完,t2才能运行,如果是希望t1先干%50的活,就让t2开始行动,join也无能为力,使用sleep,指定一个休眠时间,但是t1执行这些活,到底需要多少时间,不好估计。
wait进行阻塞,某个线程调用wait方法,就会进入阻塞,此时就处在WAITING,object.wait(),wait不加任何参数就是死等,一直等到其他线程唤醒它。wait加参数,指定了等待的最大时间
wait的带有等待时间的版本,看起来和sleep有点像,其实还是有本质区别的,虽然都能指定等待时间,虽然也能被提前唤醒(wait时使用notify唤醒,sleep是使用interrupt唤醒),但是notify唤醒wait
不会有异常,interrupt唤醒sleep则是出异常了。
wait,notify,notifyall 这几个方法,都是Object类的方法。
wait sleep区别总结:
1.相同点:都是使线程暂停一段时间
2.wait 是Object类的方法,而sleep是Thread类的方法
3.wait必须在synorchnoized修饰的代码块或方法里使用,而sleep在哪都可以
4.调用wait,线程进行BLOCK状态,调用wait线程会主动释放锁,而线程调用sleep会处于TIMED_WAIT状态,不涉及锁操作.
public static void main(String[] args) {
Object lock=new Object();
Thread t1=new Thread(() -> {
int i=0;
for(i=0;i<5;i++)
{
;
}
System.out.println("线程1已经执行完,去通知线程2执行");
synchronized (lock) {
lock.notify();
}
});
Thread t2=new Thread(() ->{
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("线程2已经执行完");
});
t2.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
t1.start();
}
}
像这段代码,我们在t2里面调用lock.wait()就是让t2线程等待t1让它先干完活,虽然这里阻塞了,阻塞在synchronized代码块里,实际上这里的阻塞是释放了锁的,此时其他线程是可以获取到object这个对象的锁的,此时这里的阻塞,就出在WAITING状态, 当t1干完活后,再调用lock.notify()通知线程2干活,线程2此时重新获取到锁。
我们要注意,启动线程的时候,先让t2先启动,过段时间再让t1先干活,因为如果先启动t1,可能会存在t1线程已经执行完了,而t2线程此时再执行,此时线程2就会一直阻塞下去,t1的notify已经执行完了,也就不起作用了。
t1.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
t2.start();
像这样线程2就会一直阻塞下去,没法被唤醒。
这里面还需要注意的一个点是wait(),notify()这两个方法需要搭配synchronized使用,为啥呢?
因为wait操作,先释放锁,进行阻塞等待,收到通知以后,尝试获取锁,并且在获取锁之后,继续往下执行。
notify和notifyAll区别
多个线程wait的时候,notify随机唤醒一个,notifyAll 所有线程都唤醒,这些线程在一起竞争锁。
三个线程分别只能打印ABC,且保证按照ABC的顺序打印。
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(() -> {
System.out.println("A");
synchronized (locker1) {
locker1.notify();//通知线程2,唤醒线程2
}
});
Thread t2 = new Thread(() -> {
synchronized (locker1) {
try {
locker1.wait();//等待线程1
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("B");
synchronized (locker2) {
locker2.notify();//通知线程3唤醒线程3
}
});
Thread t3 = new Thread(() -> {
synchronized (locker2) {
try {
locker2.wait();//等待线程2
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("C");
});
t2.start();//先让t2 t3先启动,防止线程1的notify提前被调用,线程2就无法被唤醒
t3.start();
Thread.sleep(100);
t1.start();
}
八.单例模式:单个实例(对象)
在有些场景中,有的特定的类,只能创建一个实例类,不应该创建多个实例,java里实现单例模式有很多种,我们主要介绍两种:(1)饿汉模式 (2)懒汉模式
(1)饿汉模式
public class Signale {
//在此处,先把这个实例创建出来
public static Signale signale=new Signale();
//如果需要使用这个唯一实例,统一通过Singale.getInstance()
public static Signale getInstance()
{
return signale;
}
//把构造方法设为Private,在类外面,就无法通过new的方式来创建Singale实例了。
private Signale()
{
}
}
像这里static修饰这个对象,就会保证这个实例是唯一的,保证这个实例在一定时机被创建起来 。
我们用private这个属性,在类外面就无法通过new的方式来创建这个Singale实例了。
类对象本身和static没关系,而是类里面使用static修饰的成员会作为类属性,也就相当于这个属性对应的内存空间在类对象里面
class text123
{
public int a;
public static int b;
}
public class Thread5 {
public static void main(String[] args) {
text123 t1=new text123();
text123 t2=new text123();
//两个实例分别指向两份不同的a
t1.a=20;
t2.a=30;
//被static 修饰的b只有一份,两次都指向同一个b
text123.b=10;
text123.b=20;
System.out.println("t1="+t1.a);
System.out.println("t2="+t2.a);
System.out.println(text123.b);
}
}
类加载:运行一个java程序,就需要让java进程能够找到并读取对应的.class文件就会读取文件内容,并解析并构成类对象,这一系列的过程操作,叫做类加载
(2)单例模式的懒汉模式实现:
public class Signallazy {
public static Signallazy signallazy=new Signallazy();
public Signallazy getInstance()
{
if(signallazy==null)
{
signallazy=new Signallazy();
}
return signallazy;
}
private Signallazy()
{
}
}
我们再把饿汉模式拿过来看一下:
public class Signale {
//在此处,先把这个实例创建出来
public static Signale signale=new Signale();
//如果需要使用这个唯一实例,统一通过Singale.getInstance()
public static Signale getInstance()
{
return signale;
}
//把构造方法设为Private,在类外面,就无法通过new的方式来创建Singale实例了。
private Signale()
{
}
}
这两个模式哪个是安全的呢?
像饿汉模式我们知道它只涉及读,不涉及修改,那么它应该是安全的。
而懒汉模式我们发现它涉及读和修改两种操作,如果不给它加锁,它是不安全的。
如果是一个线程,那么是安全的,但是如果是多个线程,像这里的t2线程就会读到“脏数据",也就是未修改后的值。
那么我们应该加上锁
public class Signallazy {
public static Signallazy signallazy=new Signallazy();
public Signallazy getInstance()
{
synchronized (Signallazy.class) {
if (signallazy == null) {
signallazy = new Signallazy();
}
}
return signallazy;
}
private Signallazy()
{
}
}
那这样是不是就完美了呢?我们知道加锁操作是有开销的 ,当signallazy一旦不为空时,此是会直接返回signallazy,相当于一个是比较操作,一个是返回操作,这两个都是读操作,而不涉及修改操作,此时就不需要加锁了,因此我们在外边在判断一下signallazy是否为空,是否需要加锁就行
public class Signallazy {
public static Signallazy signallazy = new Signallazy();
public Signallazy getInstance() {
if (signallazy == null) { //第一个if用来判断是否需要加锁,
synchronized (Signallazy.class) {
if (signallazy == null) {//第二个if用来判断是否需要new对象
signallazy = new Signallazy();
}
}
}
return signallazy;
}
private Signallazy()
{
}
}
第一个if语句用来判断是否需要加锁,第二个if语句用来判断是否 需要new对象。
那么代码这样写是不是就安全了吗?我们明白只有第一次读才是读的内存,后面读的都是寄存器和cache,内存可见性问题,另外还会涉及到指令重排序问题。
指令重排序问题:本质上是编译器优化出了问题。
signallazy=new Signallazy() 拆分成三个步骤:1.申请内存空间 2.在内存空间里构造合法的对象
3.把内存空间的地址赋值给引用signallazy.
如果编译器为了提高效率,调整代码顺序,出现指令重排序的问题正常顺序是1、2、3,这时可能为1、3、2如果是单线程,2和3顺序颠倒不会出现问题,但是如果是多线程,假设线程1按照1、3、2执行,当执行完3,执行2之前,线程1被切除CPU,线程2执行,在线程2看起来此处引用非空,就直接返回了,但是由于t1还没执行2操作,此时t2拿到的是一个非法对象,还没构造完成的不完全对象。
针对内存可见性和指令重排序问题,我们需要用volatile!!!!
public volatile static Signallazy signallazy = new Signallazy();
用volatile修饰一下signallazy就可以了。
下边这就是单例模式懒汉模式的安全版本了。
public class Signallazy {
public volatile static Signallazy signallazy = new Signallazy();
public Signallazy getInstance() {
if (signallazy == null) { //第一个if用来判断是否需要加锁,
synchronized (Signallazy.class) {
if (signallazy == null) {//第二个if用来判断是否需要new对象
signallazy = new Signallazy();
}
}
}
return signallazy;
}
private Signallazy()
{
}
}
饿汉模式指在程序启动时就创建单例对象,无论之后是否会用到该对象。因此,饿汉模式相对较简单,但可能会降低程序的启动速度和占用内存,因为即使在不需要使用该对象的情况下也会创建它。
懒汉模式指在第一次使用单例对象时再创建它,避免了一开始就创建对象的开销。尽管懒汉模式的实现比较复杂,但可以节省内存并提高程序的启动速度。
总的来说,饿汉模式适合单例对象在程序运行期间一直被使用的情况,而懒汉模式则更适合单例对象的使用不那么频繁,而且占用内存较大的情况。