本文会开始去总结在Java中的使用到的一些多线程的知识点,阅读本文需要有一定的多线程使用经验。
本篇主要是
- 一些线程的基础知识
synchronized
使用Object
类自带的线程同步方法Thread
类的API说明
其他的系列:
前置知识
线程创建&状态
在开始之前,我们需要先对线程有一定的认识。
首先是线程的创建方式:
- 单纯的使用Thread,匿名类or实现类
- 使用Runable&Thread
- 使用Runable&FutureTask&Thread
- 使用Callable&FutureTask&Thread
线程的数据:每一个线程都会有自己单独的内存分配区域,叫做线程栈,对于堆内存,是所有线程共用的。所以假如有多个线程需要对同一个堆内存对象进行修改访问,就可能会出现并发的问题。即会有死锁,数据不正确等异常。
线程的状态:
- 初始:新创建一个线程对象,没有调用start()方法
它们2. 运行:java线程中将就绪(reday)&运行中(running)统称为运行,调用了线程对象的start()方法之后,该线程就开始等待被内核调度,等待获取cpu的使用权,这时候是处于就绪状态,就绪状态的线程获取cpu的执行就是运行中的状态 - 阻塞:表示线程处于阻塞
- 等待:进入该状态的线程需要等待其他线程做出一些特定动作(通知or中断)
- 超时等待:该状态不同于等待,他可以在指定的时间后自行返回
- 终止:表示线程已经执行完毕
文章地址:
在running的时候:
- 获取锁失败,进入阻塞状态(竞争不过别的线程),获取锁成功之后则进入running。
- 获取到锁之后调用了wait()方法,则会释放锁,则进入等待状态,调用了notify()系列方法,才会被唤醒,这块在Objec的线程同步方案中有具体的描述
- 获取wail(long),则进入超时等待,调用了notify()或者是时间到则进入runing
- 调用了sleep(long),也是进入超时等待,时间到了之后则重新进入running
- 代码执行完成,则进入终止状态
线程并发的一些概念
还有一个前置的知识是关于线程并发的概念的
- 原子性:即一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就不执行。原子性是拒绝多线程操作的,无论cpu是多核还是单核,具有原子性的量,同一个时刻只能有一个线程对它进行操作。java中的原子操作包括:
- 基本类型的读取和赋值操作,而且赋值必须是值赋给变量,变量之间的互相赋值不是原子操作,其中的类似
i++
不是原子操作 - 所有的引用reference的赋值操作。
- java.concurrent.Atomic.*包中的类的一切操作
- 基本类型的读取和赋值操作,而且赋值必须是值赋给变量,变量之间的互相赋值不是原子操作,其中的类似
- 可见性:指多个线程访问同一个变量的时候,一个线程修改了变量的值,其他线程能够立即看到修改的值。在多线程环境下,一个线程对共享变量的操作是对其他线程不可见的。Java提供了
volatile
关键字来保证可见性,当一个变量被volatile
修饰之后,表示线程本地内存无效,当一个线程修改共享变量之后他会立即同步到主内存中,其他的线程读取共享变量的时候,也会直接从主内存读取。当然,synchronized
和Lock
都可以保证可见性,它们能保证同一个时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会对变量的修改刷新到主内存中。 - 有序性:即程序执行的顺序是按照代码的先后顺序。如果在本线程内观测,所有的操作都是有序的,如果在一个线程中观测另一个现场,所有的操作都是无序的。在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序的,当然重排序不会影响单线程的运行结果,但是对于多线程则会有影响。java提供了
volatile
来保证一定的有序性,最著名的就是单例模式中的DLC(DCL之单例模式)
Java锁的特色:
- 互斥性:即一次只允许一个线程持有某个特定锁,一次就只有一个线程能够使用该共享数据
- 可见性:它需要确保在释放锁之前对共享数据做出的修改对于后来获取到锁的另一个线程是可见的,即当一条线程修改了共享变量,新值对于其他线程是可以立即得知的,如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或者是不一致的值,这种情况可以使用
valatile
关键字来避免,他的使用条件有- 对变量的写操作不依赖于当前值
- 改变量没有包含在具体其他变量的不定式中。
乐观锁与悲观锁
乐观锁总是假设最好的情况,每次去拿数据的时候都认为是别人不会修改这个数据,所以不会上锁,只要对数据进行更新的时候判断一下在此期间别人有没有去更改这个数据,可以使用版本号机制与CAS算法实现。
CAS(Compare And Swap)是一种常见的乐观锁,它有三个操作数:内存地址V,旧值A,新值B,只有当当前内存地址V上的值是A,B才会写到V上,否则操作失败。目前Java对CAS的支持对应的是java.consurrent.atomic
包下的类。关于这部分的实例会在Java多线程篇二中说明。
悲观锁总是假设最坏的情况,每次去拿数据之前都认为别人会修改,所以每次拿数据的时候都会先上锁,这样别人想拿就需要阻塞直到它完成。Java中的synchronized
和ReentrantLock
等独占锁就是悲观锁,在JDK6之后引入了一些列的synchronized
优化,比如适应性自旋锁,锁消除,偏向锁,轻量级锁等,经过优化之后的synchronized
不再是单一的重量级锁了,而是有无锁,偏向锁,轻量级锁和重量级锁四种状态,关于这部分参考文章:字节面试官:synchronized能保证可见性吗
synchronized关键字
我们先说synchronized
关键字,因为Objec的线程同步方案中有具体的描述的使用需要结合到该关键字。
先说synchronized
修饰代码块:这种情况下它的锁是对象锁:例如
1 | public class Hello { |
打印的结果只能是:100000 & 0
或者是-100000&0
。当t1
运行到add
方法的时候,它就会占用object
这个对象锁,然后t2
只能等待t1
释放,在使用synchronized
包裹的代码块执行完成之后,就会释放object
这个对象锁,t2
就会执行了。当然也可能是t2
先执行先获取到object
对象锁,看谁先占用cpu的时间片。
synchronized
作用于方法。例如下面的例子:
1 | public class Hello { |
假如synchronized
是修饰静态方法
,则它的锁对象是当前类的Class
对象,假如是成员方法,则是当前成员对象。上面的例子就是当前类的Class
对象
synchronized锁是可重入的,并且他也有一个计数器,在同一个线程内synchronized代码块再次被调用,被相同的锁的synchronized代码块时,这个仙童的线程再次进入被锁锁住的代码块,锁的计数器再次自增1,到达2,所以需要等待锁的计数器下降为0时才能释放锁,即需要两层的synchronized代码块都被执行完成之后才会释放锁,
可重入性:即同一个现场多次试图获取它所占用的锁,请求会成功,当释放锁的时候,直到重入次数清零,锁才释放完毕。
例如:
1 | // 调用reduce() ,进入到了reduce1()之后锁自增到2,需要reduce1()执行完,然后reduce()也执行完之后才会释放锁 |
synchronized
的缺点:它是悲观锁,执行效率较差。比如synchronized
中的方法是有读写文件的,这时候他也不会去释放锁,他可以通过Lock
去解决,会在Java多线程篇二中说明Lock
的使用。
synchronized
能够做到原子性和可见性,但是无法阻止指令重排,无法做到有序性,有序性可以通过volatile
实现。关于原子性就不说了,关于可见性,synchronized
的规定是:
- 线程解锁之前,必须把自己本地内存中的共享变量的最新值刷新到主内存中
- 线程加锁之前:将清空本地内存中共享变量,从使用共享内存的时候从主内存中获取进行读写。
参考文章:内存可见性以及synchronized实现可见性和Volatile实现可见性
Objec的线程同步方案
我们可以使用notify/wait
机制来实现线程之间的同步,比如可以实现某一个线程等待别的线程执行完成之后再执行。也可以用于避免死锁的问题(记得调用notify
相关的方法),比如避免某个锁被某个线程获取之后进入了阻塞or没有释放的时候,别的线程无法获取到该锁就会进入阻塞也可能进入死锁的状态。
我们的notify
和wait
是需要搭配synchonized
一起使用,假如不在synchronized
方法或者是代码块中执行,则会抛出IllegalMonitorStateException
异常。例如下面的代码:
1 | // 想要实现先打印t2在打印t1的功能,下面的代码就会报 java.lang.IllegalMonitorStateException |
为什么我们需要使用synchonized
来包裹wait/notify
呢,假设一个生成-消费的模型,例如下面的伪代码:
1 | //生产者 |
在多线程环境下,上面的代码存在的问题是:假如count的时候,执行了消费者线程,然后判断符合进入了条件判断,然后线程切换到了生产者,执行完成了,调用了notify()
,但是我们的消费线程并不是处于wait
状态,这就导致了后面再去wait
的时候已经丢失了notify()
,这就是lost wake up
的问题。即没有保证原子性。要解决这个问题,我们就需要给生成&消费者共用一把锁,例如:
1 | //生产者 |
但是还是会有问题,即lock()
的锁与我们调用wait()
不是同一个,我们调用到了wait()
的时候,会释放当前的对象锁,这样就会造成死锁的问题(假如lock()与wait()
也是使用同一个对象,除了synchronized
之外我不太清楚是否还有别的方式)。
我们使用synchronized
,把wait/notify
方法调用包裹起来,然后它们使用同样的锁就可以正常了,比如下面的:
1 | public static void main(String[] args) throws ExecutionException, InterruptedException { |
通过上面我们可以看到它可以保证原子性的同时也不会造成死锁,因为我们的synchronized
与wait/notify
使用同一个锁,在调用wait
的时候,它会把自己代码块的synchronized
的锁也一起释放掉。 参考文章:
关于线程自带的一些方法和Object自带的方法,我们会在下面一一说到。
wait()
,锁对象调用该方法之后,则调用线程进入了等待的状态,调用线程释放对象锁,调用的是wait(0)
,表示直接释放锁,没有超时重新进行running(即可以去抢夺cpu执行的权利,只能等待notify
)。wait(long timeout)
,与wait()
类似,只是它等待指定的毫秒数,如果超过指定时,则自动把该线程从等待状态设置为running(可以抢cpu的执行),调用之后进入超时等待的状态。wait(long timeout int manos)
,与wait(long timeout)
,与上类似,只是它的等到时间是毫秒+纳秒,调用之后进入超时等待的状态。notify()
:唤起一个与对象锁关联的线程,一次唤醒一个,而且是任意的。假如一个锁被多个线程使用,多个线程处于等待状态则随机唤醒一个,它也是释放当前自己的锁,包含一起的synchronized
的锁。notifyAll()
:唤起与对象锁关联的所有线程,所有的线程都是切换到running。
其中wait()
调用了之后,必须是需要调用notify()
或者是notifyAll()
之后,线程才会被重新唤起,否则会一直处于等待状态。然后wait(long timeout)
与wait(long timeout int manos)
方法,使用同一个锁的时候,假如是其他线程没有调用notify()
或者是notifyAll()
方法,而且也没有释放锁
我们使用这些api可以实现一个生成者消费者的模式的基本代码:
1 | //生产者生产了10之后会交给消费者然后自己等待,消费者消费了10之后自己等待轮到生产者去生产。 |
另外一个wait(long)
的例子:
1 | //打印结果是1456237,假如t2不调用start,则在20ms之后,打印2 |
Thread的API
sleep(long)
,当前线程休眠多少毫秒,它与wait
的区别是它不会释放锁。isAlive()
,判断当前线程是否处于活动状态,true为是,在调用start之后,run方法运行结束之前是true,参考isAlive()方法解释isDaemon()
,判断是否是守护线程,较少用到,参考守护线程 java.lang.Thread.isDaemon()方法interrupt()
,它是一个成员方法,调用该方法不会立马停止线程,而是把中断标识设置为true。假如我们有调用sleep()/wait()
等会抛出中断异常的方法的时候,它会接受到中断异常,假如我们catch了该异常则线程不会停止。没调用是不会收到的,它需要在调用start
方法之后才起效。interrupted()
,它是一个类方法,测试当前所在的运行线程是否已被中断,返回一个boolean值并清除中断状态,第二次调用的时候中断状态会返回false。isInterrupted()
,该方法仅仅返回中断状态,不会清除中断标志。setProperty()
,需要再start()
方法之前调用,在没有指定线程优先级的时候,线程都带有普通的优先级,线程的优先级可以分为1-10,10代表最高优先级,1是最低,普通为5,优先级高的在运行时基于有限,但是不能保证线程启动后立刻进入运行状态。setName()
,设置线程名称,也可以在创建线程的时候传入
1 | // 中断方法的例子 |
还有两个比较关键的方法:
join
:他可以让线程可以串行执行。比如在线程A中,调用了线程B的join()
方法,则是需要等待线程B执行完成之后,才会继续执行A,它的原理是通过调用wait(0)
方法实现的,调用的join(0)
,该方法需要再调用了对应现场的start
之后再调用才会起效,参考文章:java 线程方法join的简单总结join(long)
和join(long , int )
:简单的描述就是让对应调用了join
方法的线程先执行传入的时间,然后再互相竞争cpu的执行权yield
:它是一个类方法,会暂停当前正在执行的线程对象,并且执行其他的线程,但是可能会不起效。它相当于把当前的现场从running中的运行状态转为就绪状态。在后续cpu分配过程中有可能会再次进入运行状态,参考文章yield()方法详解
1 | // join的例子 |
废弃的方法:
stop()
:暂停线程suspend()
:挂起线程resume()
:恢复线程