Java线程
线程状态
Thread类里的枚举State描述了线程的六个状态,分别是:
新生(New):刚刚创建的线程,尚未启动
执行(Runnable):正在JVM中执行的线程处于这种状态,实际又可分为就绪态(Ready)与运行态(Running)
阻塞(Blocked):jdk官方解释如下:
Thread state for a thread blocked waiting for a monitor lock.
A thread in the blocked state is waiting for a monitor lock
to enter a synchronized block/method or reenter a synchronized block/method after calling
处于阻塞态的线程等待monitor lock ,即在线程处于可运行状态的时候会尝试获取锁,如果他没有获取到锁,那么这个进程就处于阻塞态。如果获取到了锁,就转成了可运行态。
无限等待(Waiting):当我们调用
wait()
时会强制当前线程等待,直到某个其它线程在同一个对象上调用notify()
或notifyAll()
方法。因此,当前线程必须拥有对象的监视器。根据 Java docs 的说法,这可能发生在
- 我们已经为给定对象执行了同步实例方法
- 我们已经在给定对象上执行了 synchronized 块的主体
- 通过为 Class 类型的对象执行同步静态方法
计时等待(Timed Waiting):和无线等待类似,这里指定了等待时间,在这个时间内一直等待被唤醒。常见的
Thread.sleep
方法和wait(long time)
方法。
可运行态调用Thread.sleep
方法或wait(long time)
方法进入此状态。
等待时间内,获取到了锁,被唤醒进入可运行状态。
等待时间到,获取到了锁,被唤醒进入可运行状态。
等待时间结束,没有获取到锁,进入阻塞态。死亡(Terminated):已退出的线程处于此态,其中让线程进入死亡态的方法是stop()和destroy(),但是jdk官方均不推荐使用(已过时),一般的做法是让线程自然死亡
下图为Java线程状态转换图(有瑕疵),其中Running和Ready-to-tun均属于Runnable(执行态),Timed Waiting和Waiting态事实上也属于Blocked阻塞态
线程相关方法:
sleep()
Thread.sleep(1000)
指让线程进入阻塞状态,并在1000ms后自动进入就绪态
注意:在哪个线程里面调用sleep()方法就阻塞哪个线程,==线程调用sleep()不会释放锁!==
yield()
礼让线程,让当前正在执行的线程暂停,不阻塞线程,而是直接让线程由运行态进入就绪态,待cpu调度器重新调度。==但礼让不一定成功,即有可能接下来经调度后CPU依然执行该线程!==
join()
join可以翻译为插入,插队,很多情况下主线程创建并启动子线程,如果子线程中要进行大量的耗时运算,主线程将可能早于子线程结束。如果主线程需要知道子线程的执行结果时,就需要等待子线程执行结束了。主线程可以sleep(xx),但这样的xx时间不好确定,因为子线程的执行时间不确定,join()方法比较合适这个场景。 在子线程中调用join()会让主线程会等待子线程结束之后才能继续运行”。==主线程的代码块中,如果碰到了t.join()方法,此时主线程需要等待(阻塞),等待子线程结束了(Waits for this thread to die.),才能继续执行t.join()之后的代码块。==
wait()&¬ify()
wait() 与 notify/notifyAll() 是Object类的方法,在执行两个方法时,要先获得锁。
- Object.wait() —— 暂停一个线程
- Object.notify() —— 唤醒一个线程
从以上的定义中,我们可以了解到以下事实:
- 想要使用这两个方法,我们需要先有一个对象 Object。
- 在多个线程之间,我们可以通过调用同一个对象的
wait()
和notify()
来实现不同的线程间的可见。
wait()方法是让当前线程等待,即让线程释放了对共享对象的锁。wait(long timeout)方法可以指定一个超时时间,过了这个时间如果没有被notify()唤醒,则函数还是会返回。如果传递一个负数timeout会抛出IllegalArgumentException异常。
- 当线程执行wait()时,会把当前的锁释放,然后让出CPU,进入等待状态。 即==告知被调用的线程退出并进入等待状态,直到其他线程进入相同的监视器并调用 notify( ) 方法才有可能唤醒该等待线程==
- 当执行notify/notifyAll方法时,会唤醒一个处于等待该对象锁的线程,然后继续往下执行,直到执行完退出对象锁锁住的区域(synchronized修饰的代码块)后再释放锁
- 无论是执行对象的 wait、notify 还是 notifyAll 方法,必须==保证当前运行的线程取得了该对象的控制权(monitor)==,可以使用
synchronized (object)
来取得对于 object 对象的控制权 - notify/notifyAll()执行后,并不立即释放锁,而是要等到执行完临界区中代码后,再释放。
例1:启动两个线程, 一个输出 1,3,5,7…99, 另一个输出 2,4,6,8…100 最后按序输出1,2,3,4,5…100.
1 |
|
例2:使用wait与notify来实现生产者/消费者模式.
该模型中,最关键就是内存缓冲区为空的时候消费者必须等待,而内存缓冲区满的时候,生产者必须等待。即多线程对临界区资源的操作时候必须保证在读写中只能存在一个线程,所以需要设计锁的策略。
线程同步
JMM
Java 内存模型是一种规范,定义了很多东西:
所有的变量都存储在主内存(Main Memory)中。
每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的拷贝副本。
线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
不同的线程之间无法直接访问对方本地内存中的变量。
如上图所示:线程间共享的变量存储在主内存中,每个线程对应有自己的本地内存,用来存储共享变量的副本,从主内存到线程本地内存的读取和写入操作由JMM控制。线程1和线程2之间的通信需要经过两个步骤:线程1将修改后的共享变量副本写入主内存,然后线程2从主内存读取共享变量并拷贝到自己的本地内存。
线程/工作内存(高速缓存)/主内存 三者的关系
所有变量都存储在主内存中,主内存是共享内存区域,所有线程都可以访问
每个线程都有一个独立的工作内存,用于存储线程私有的数据
线程对变量的操作必须在工作内存中进行(线程安全问题的根本原因)
a. 首先要将变量从主内存拷贝到线程的工作内存中, 不允许直接操作主内存中的变量
b. 每个线程操作自己工作内存中的变量副本,操作完成后再将变量写回主内存
c. 多个线程对一个共享变量进行修改时,都是对自己工作内存中的副本进行操作,相互不可见, 所以主内存最后得到的结果是不可预知的
d. 不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
原子性
指一个线程的操作是不可中断的,即在多个线程一起执行的时候,一个线程的操作一旦开始,就不会被其他线程干扰, 只能当前线程执行完后其他线程才可以执行,想要理解原子性,首先要知道上下文切换这一概念,上下文切换指的是内核(操作系统的核心)在CPU上对进程或者线程进行切换 ,我们拿java中的代码来说:
1 | //假设i的初始值为0 |
这段代码在底层至少需要三条CPU指令
1:把变量i的值从内存 *load* 到CPU寄存器
2:在CPU中执行+1
3:将结果 ***store***到内存,当然有可能只存到缓存(更严谨的说应该是写缓冲区),并没有刷新到主存中。
==虽然每条指令具备原子性,但在进行上下文切换,可能发生在任意一条CPU指令执行完之后(注意时CPU指令级别),导致线程操作被中断,从而失去原子性==,因此在多线程并发时容易造成原子性问题
解决方案(volatile关键字不能解决原子性问题!):
1. synchronize: 同步锁
2. CAS
3. Lock锁
可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
- Java是利用volatile
关键字来提供可见性的。 当变量被volatile修饰时,这个变量被修改后会立刻刷新到主内存,当其它线程需要读取该变量时,会去主内存中读取新值。而普通变量则不能保证这一点。除了volatile之外,Java还有两个关键字能实现可见性,即synchronized
和final
。
- synchronized的可见性是由“对一个变量执行lock(加锁)操作之前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中 重新获取最新的值,对一个变量执行unlock(解锁)操作之前,必须先把此变量同步回主内存中。即lock操作前先清空自身工作内存里的值再去主存里取最新的,unlock操作前先把工作内存中的变量存入主内存中
- final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就一定能看见final字段的值,且该值不可修改。
有序性
在单线程程序中代码逐行执行,但是在多线程并发时,程序的执行就有可能出现乱序,多线程执行程序时, 因为为了提高性能,编译器和处理器常常会自动对指令做重排序,目的是进行相关的优化, 指令重排序使得代码在多线程执行时可能会出现一些问题
解决方案:
volatile: 其在指令序列中插入内存屏障(一组处理器指令), 防止指令重排序,可以保证有序性,volatile关键字禁止指令重排序有两层意思:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见,在其后面的操作肯定还没有进行。
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
这里简单讲一下内存屏障,内存屏障可以分为以下几类:
- LoadLoad 屏障:对于这样的语句Load1,LoadLoad,Load2。在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- StoreStore屏障:对于这样的语句Store1, StoreStore, Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- LoadStore 屏障:对于这样的语句Load1, LoadStore,Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- StoreLoad 屏障:对于这样的语句Store1, StoreLoad,Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。
Java内存模型采取保守的内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
(这块理解的不够深刻,后面再来看😥)
synchronize: 同步锁
Lock锁
如何实现线程同步
实现线程同步的办法就是加锁, 在Java中每个对象或类都可以当做锁使用,这些锁称为内置锁。 Java中内置锁都是互斥锁。也就是说一个线程获取到锁,其他线程必须等待或阻塞。 如果占用锁的线程不释放锁,其他线程将一直等待下去。锁在同一时刻,只能被一个线程持有。如果锁是作用于对象,称对象锁。如果锁作用整个类称为类锁。在java中锁有以下几种类型:
同步代码块锁 synchronized (obj){ }
同步方法锁 private synchronized void makeWithdrawal(int amt) {}
volatile+CAS无锁化方案
Lock锁 ReentrantLock、ReentrantReadWriteLock
sychronized
sychronize介绍
synchronized是Java中的关键字。使用synchronized关键字是锁的一种实现。
synchronized的加锁和解锁过程不需要程序员手动控制,只要执行到synchronized作用范围会自动加锁(获取锁/持有锁),执行完成后会自动解锁(释放锁)。
synchronized可以保证可见性,因为每次执行到synchronized代码块时会清空线程区。
synchronized 会不禁用指令重排,但可以保证有序性。因为同一个时刻只有一个线程能操作。
synchronized 可以保证原子性, 一个线程的操作一旦开始,就不会被其他线程干扰, 只能当前线程执行完, 其他线程才可以执行。
synchronized 在Java老版本中属于重量级锁(耗费系统资源比较多的锁),随着Java的不停的更新、优化,在Java8中使用起来和轻量级锁(耗费系统资源比较少的锁)已经几乎无差别了。
主要分为下面几种情况:
- 修饰普通方法, 非静态方法(对象锁) 需要在类实例化后, 再进行调用
- 修饰静态方法(类锁)静态方法属于类级别的方法, 静态方法可以类不实例化就使用
- 修饰代码块(对象锁、类锁)
修饰代码块
语法:
synchronized(锁){
// 内容
}
锁代码块是非常重要的地方。添加锁的类型是Object类型。
运行过程:
多线程执行时,每个线程执行到这个代码块时首先会判断是否有其他线程持有这个锁,如果没有,执行synchronized代码块。如果已经有其他线程持有锁,必须等待线程释放锁。当一个线程执行完成synchronized代码块时会自动释放所持有的锁。
锁为固定值
当锁为固定值时,每个线程执行到synchronized代码块时都会判断这个锁是否被其他线程持有,哪个线程抢到先执行哪个线程。当抢到的线程执行完synchronized代码块后,会释放锁,其他线程竞争,抢锁,抢到的持有锁,其他没抢到的继续等待
由于值固定不变, 所有的对象调用加锁的代码块, 都会争夺锁资源, 属于类锁
1 | public class Test1{ |
锁为this
当锁为this时,需要看线程中是否为同一个对象调用的包含synchronized所在的方法。这种写法也是比较常见的写法
同一个对象调用加锁方法时:
如果是同一个对象调用synchronized所在方法时,this代表的都是一个对象。this就相当于固定值。所以可以保证结果正确性, 属于对象锁
1 | public class Test1{ |
不同对象调用加锁方法时:
如果不是同一个对象调用synchronized所在方法时,this所代表的对象就不同。相当于锁为不同内容时。锁失效
1 | public class Test1{ |
锁为class
锁为Class时,是一个标准的类锁, 所有的对象调用加锁的代码块都生效
1 | public class Test1{ |
修饰实例方法
锁类型: 使用synchronized修饰实例方法时为对象锁 锁是this
锁范围: 锁的范围是加锁的方法
锁生效: 必须为同一个对象调用该方法该锁才有作用
正确情况
1 | public class Test1{ |
修饰静态方法
锁类型: 使用synchronized修饰静态方法时为类锁 锁是当前类的字节码对象
锁范围: 锁的范围是加锁的方法
锁生效: 该类所有的对象调用加锁方法, 锁都生效
代码演示
1 | public class Test1{ |
总结
不要将run()定义为同步方法
同步实例方法的同步监视器是this(对象this锁);同步静态方法的监视器是类名.class(类锁)
对于synchronized锁(同步代码块和同步方法),如果正常执行完毕,会释放锁。如果线程执行异常,JVM也会让线程自动释放锁。所以不用担心锁不会释放。
synchronized锁的缺点:
如果获取锁的线程由于要等待IO或其他原因(如调用sleep方法)被阻塞了,但又没有释放锁,其他线程只能干巴巴地等待,此时会影响程序执行效率。甚至造成死锁;
只要获取了synchronized锁,不管是读操作还是写操作,都要上锁,都会独占。如果希望多个读操作可以同时运行,但是一个写操作运行,无法实现。
Lock锁
鉴于synchronized的一些缺点,JDK1.5中推出了新一代的线程同步方式:Lock锁
先介绍Lock接口,通过查看Lock的源码可知,Lock接口有6个方法。 下面来逐个讲述Lock接口中每个方法的使用,lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()是用来获取锁的。unLock()方法是用来释放锁的。newCondition()在后面的线程通信中使用。
1 | public interface Lock { |
在Lock中声明了四个方法来获取锁,那么这四个方法有何区别呢?
- lock() 首先lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。 如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被释放,防止死锁的发生。通常使用Lock来进行同步的话,是以下面这种形式去使用的:
1 | Lock l = ...; |
- tryLock() 该方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。拿不到锁时不会一直在那等待。
- tryLock(long time, TimeUnit unit) 该方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false。如果如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
- LockInterruptibly() lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。
ReentrantLock锁
ReentrantLock,意思是“可重入锁”。ReentrantLock是唯一实现了Lock接口的非内部类,并且ReentrantLock提供了更多的方法。
ReentrantLock锁在同一个时间点只能被一个线程锁持有; 而可重入的意思是,ReentrantLock锁,可以被单个线程多次获取。
(未完待续。。。。。。。。。。。。。。。。。。。。)