(一)Java 多线程开发
1.1)线程状态
(1)Java线程 6 种状态
public static enum Thread.State 用来描述一个线程的状态,包括6种状态
注:这里的六中状态是JVM层面的状态,与OS底层的线程状态不同(OS底层线程状态有5种,如下图)
Java 的线程状态倾向于描述线程,而OS的线程状态倾向于描述CPU。
而对于Java而言,Java的线程类型都来源于Thread 类下的 State 这一内部枚举类中所定义的状态:
- NEW 新建状态 当用new操作符创建一个线程后,如Thread thread = new Thread(),此时线程处在新建状态。 当一个线程处于新建状态时,线程中的任务代码还没开始运行。 这里的开始执行具体指调用线程中start方法。(一个线程只能start一次,不能直接调用run方法,只有调用start方法才会开启新的执行线程,接着它会去调用run。在start之后,线程进入RUNNABLE状态,之后还可能会继续转换成其它状态。)
- RUNNABLE 就绪状态(可执行状态) 也被称为“可执行状态”。一个新创建的线程并不自动开始运行,要执行线程,必须调用线程的start()方法。当调用了线程对象的start()方法即启动了线程,此时线程就处于就绪状态。 处于就绪状态的线程并不一定立即运行run()方法,线程还必须同其他就绪线程竞争CPU,只有获得CPU使用权才可以运行线程。比如在单核心CPU的计算机系统中,不可能同时运行多个线程,一个时刻只能有一个线程处于运行状态。对与多个处于就绪状态的线程是由Java运行时系统的线程调度程序(thread scheduler)来调度执行。 除了调用start()方法后让线程变成就绪状态,一个线程阻塞状态结束后也可以变成就绪状态,或者从运行状态变化到就绪状态。 对于Java虚拟机的RUNNABLE状态,包含OS的Ready、Running。(由于现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片”方式进行抢占式轮转调度,其中上下文切换过程很快,因此ready与running状态切换很快,对于RUNNABLE状态,就没有切换的意义了。) 以及部分waiting状态(即OS状态下的阻塞式I/O操作),这些状态可统一归纳为RUNNABLE状态的官方定义: 处于 runnable 状态下的线程正在 Java 虚拟机中执行,但它可能正在等待来自于操作系统的其它资源,比如处理器或者其他I/O设备等。(CPU、硬盘、网卡等资源,若在为线程服务,就认为线程在"执行")
- BLOCKED 阻塞状态 线程在获取锁失败时(因为锁被其它线程抢占),它会被加入锁的同步阻塞队列,然后线程进入阻塞状态(Blocked)。处于阻塞状态(Blocked)的线程放弃CPU使用权,暂时停止运行。待其它线程释放锁之后,阻塞状态(Blocked)的线程将在次参与锁的竞争,如果竞争锁成功,线程将进入就绪状态(Runnable) 。 注意区分BLOCKED 状态与一般的I/O阻塞:BLOCKED状态特指被 synchronized 块阻塞,即是跟线程同步有关的一个状态。 进程同步 线程同步机制用于解决多线程之间竞争关系——争夺锁。在ava 在语言级直接提供了同步的机制,也即是 synchronized 关键字:
synchronized(expression) {……}
它的机制是这样的:对表达式(expresssion)求值(值的类型须是引用类型(reference type)),获取它所代表的对象,然后尝试获取这个对象的锁: 如果能获取锁,则进入同步块执行,执行完后退出同步块,并归还对象的锁(异常退出也会归还);如果不能获取锁,则阻塞在这里,直到能够获取锁。如果一个线程在同步块中,则其他想进入该同步块的进程被阻塞,处于该同步块的Entry Set中,处于BLOCKED状态。 BLOCKED状态官方定义如下:
一个正在阻塞等待一个监视器锁的线程处于这一状态。(A thread that is blocked waiting for a monitor lock is in this state.)
包括两种情况: (1)进入(enter)同步块时阻塞 一个处于 blocked 状态的线程正在等待一个监视器锁以进入一个同步的块或方法。 监视器锁用于同步访问,以达到多线程间的互斥。所以一旦一个线程获取锁进入同步块,在其出来之前,如果其它线程想进入,就会因为获取不到锁而阻塞在同步块之外,这时的状态就是 BLOCKED。 (2)wait 之后重进入(reenter)同步块时阻塞 一个处于 blocked 状态的线程正在等待一个监视器锁,在其调用 Object.wait 方法之后,以再次进入一个同步的块或方法。 过程如下:
- 调用 wait 方法必须在同步块中,即是要先获取锁并进入同步块,这是第一次 enter。
- 而调用 wait 之后则会释放该锁,并进入此锁的等待队列(wait set)中。
- 当收到其它线程的 notify 或 notifyAll 通知之后,等待线程并不能立即恢复执行,因为停止的地方是在同步块内,而锁已经释放了,所以它要重新获取锁才能再次进入(reenter)同步块,然后从上次 wait 的地方恢复执行。这是第二次 enter,所以叫 reenter。
- 但锁并不会优先给它,该线程还是要与其它线程去竞争锁,这一过程跟 enter 的过程其实是一样的,因此也可能因为锁已经被其它线程据有而导致 BLOCKED。
这两种情况可总结为:当因为获取不到锁而无法进入同步块时,线程处于 BLOCKED 状态。BLOCKED状态可以看做特殊的WAITING,表示等待同步锁的状态。如果有线程长时间处于 BLOCKED 状态,要考虑是否发生了死锁(deadlock)的状况。
- WAITING 等待状态(条件等待状态) 当线程的运行条件不满足时,通过锁的条件等待机制(调用锁对象的wait()或显示锁条件对象的await()方法)让线程进入等待状态(WAITING)。处于等待状态的线程将不会被cpu执行,除非线程的运行条件得到满足后,其可被其他线程唤醒,进入阻塞状态(Blocked)。调用不带超时的Thread.join()方法也会进入等待状态。 一个正在无限期等待另一个线程执行一个特别的动作的线程处于这一状态。
一个线程进入 WAITING 状态是因为调用了以下方法:
- 不带时限的 Object.wait 方法
- 不带时限的 Thread.join 方法
然后会等其它线程执行一个特别的动作,比如:
- 一个调用了某个对象的 Object.wait 方法的线程会等待另一个线程调用此对象的 Object.notify() 或 Object.notifyAll()。
- 一个调用了 Thread.join 方法的线程会等待指定的线程结束。
进程协作 可以看出,WAITING状态所涉及的不是一个线程的独角戏,相反,它涉及多个线程,具体地讲,这是多个线程间的一种协作机制。wait/notify与join都是线程间的一种协作机制。下面分别介绍wait/notify场景与join场景 (1)wait/notify场景 当获得锁的线程A进入同步块后发现条件不满足时,应该调用 wait()方法,这时线程A释放锁,并进入所谓的 wait set 中。这时,线程A不再活动,不再参与调度,因此不会浪费 CPU 资源,也不会去竞争锁了,这时的线程A状态即是 WAITING。 现在的问题是:线程A什么时候才能再次活动呢?显然,最佳的时机是当条件满足的时候。 (此时可能存在多个类似线程A这种条件不满足的线程无法执行,与线程B争夺锁资源从而导致饥饿状态) 当另一个线程B执行动作使线程A执行条件满足后,它还要执行一个特别的动作,也即是“通知(notify)”处于WAITING状态的线程A,即是把它从 wait set 中释放出来,重新进入到调度队列(ready queue)中。 如果是 notify,则选取所通知对象的 wait set 中的一个线程释放; 如果是 notifyAll,则释放所通知对象的 wait set 上的全部线程。 但被通知线程A并不能立即恢复执行,因为它当初中断的地方是在同步块内,而此刻它已经不持有锁,所以它需要再次尝试去获取锁(很可能面临其它线程的竞争),成功后才能在当初调用 wait 方法之后的地方恢复执行。(这也即是所谓的 “reenter after calling Object.wait”,即BLOCKED状态。)
- 如果能获取锁,线程A就从 WAITING 状态变成 RUNNABLE 状态;
- 否则,从 wait set 出来,又进入 entry set,线程A就从 WAITING 状态又变成 BLOCKED 状态。
综上,这是一个协作机制,需要两个具有协作关系的线程A、B分别执行wait和notify。显然,这种协作关系的存在,线程A可以避免在条件不满足时的盲目尝试,也为线程B的顺利执行腾出了资源;同时,在条件满足时,又能及时得到通知。协作关系的存在使得彼此都能受益。 这里的协作机制也即经典的消费者-生产者问题 (2)join场景 从定义中可知,除了 wait/notify 外,调用 join 方法也会让线程处于 WAITING 状态。 join 的机制中并没有显式的 wait/notify 的调用,但可以视作是一种特殊的,隐式的 wait/notify 机制。 假如有 a,b 两个线程,在 a 线程中执行 b.join(),相当于让 a 去等待 b,此时 a 停止执行,等 b 执行完了,系统内部会隐式地通知 a,使 a 解除等待状态,恢复执行。 换言之,a 等待的条件是 “b 执行完毕”,b 完成后,系统会自动通知 a。
- TIMED_WAITING 限时等待状态 限时等待是WAITING等待状态的一种特例,主要是在时限参数和sleep方法的不同。线程在等待时我们将设定等待超时时间,如超过了我们设定的等待时间,等待线程将自动唤醒进入阻塞状态(Blocked)或就绪状态(Runnable) 。在调用Thread.sleep()方法、带有超时设定的Object.wait()方法、带有超时设定的Thread.join()方法等,线程会进入限时等待状态(TIMED_WAITING)。 一个正在限时等待另一个线程执行一个动作的线程处于这一状态。 带指定的等待时间的等待线程所处的状态。一个线程处于这一状态是因为用一个指定的正的等待时间(为参数)调用了以下方法中的其一:
- Thread.sleep
- 带时限(timeout)的 Object.wait
- 带时限(timeout)的 Thread.join
(1)带参数的wait(n) 没有参数的wait()等价于wait(0),表示线程永久等下去,等到天荒地老,除非收到通知。这种完全将再次活动的命运交给通知者可能会导致该线程永远等下去,无法得到执行的机会(当通知者准备执行notify时因某种原因被杀死,持有的锁也释放,此时线程执行的条件满足了,但等待的线程却因收不到通知从而一直处于等待状态) 此时可设置带有参数的wait(1000),等待1秒,相当于等待两个通知,取决于哪个先到:
- 如果在1000毫秒内,线程A收到了线程B的通知而唤醒,则这个闹钟随之失效;
- 如果超过了1000毫秒还没收到通知,则闹钟将线程A唤醒。
(2)sleep 进入 TIMED_WAITING 状态的另一种常见情形是调用的 sleep 方法,单独的线程也可以调用,不一定非要有协作关系。 这种情况下就是完全靠“自带闹钟”来通知。(sleep方法不会等待协作进程的通知) sleep方法没有任何同步语义,与锁无关:sleep方法不会等待协作进程的通知,当线程调用sleep方法时带了锁,则sleep期间锁仍为线程所拥有。
补充:wait 与 sleep 的区别与联系 wait和sleep均能使线程处于等待状态
- 定义 wait方法定义在Object里面,基于对象锁,所有的对象都能使用 (Java里面每一个对象都有隐藏锁,也叫监视器(monitor)。当一个线程进入一个synchronized方法的时候它会获得一个当前对象的锁。) sleep方法定义在Thread里面,是基于当前线程
- 条件 wait必须在同步环境(synchronized方法)下使用,否则会报IllegalMonitorStateException异常 sleep方法可在任意条件下使用
- 功能 wait/notify一起使用,用于线程间的通信。wait用于让线程进入等待状态,notify则唤醒正在等待的线程。 sleep用于暂停当前线程的执行,它会在一定时间内释放CPU资源给其他线程执行,超过睡眠时间则会正常唤醒。
- 锁的持有 在同步环境中调用wait方法会释放当前持有的锁 调用sleep则不会释放锁,一直持有锁(直到睡眠结束)
- TERMINATED 死亡状态 线程执行完了(completed execution)或者因异常退出了run()方法(exited),该线程结束生命周期。
总结:
- BLOCKED状态和WAITING状态对比 BLOCKED是同步(synchronized)机制下被动阻塞等待获取同步锁的状态,处于running(OS意义下)状态的线程可通过加同步锁(Synchronized)被动进入BLOCKED状态。 WAITING是异步(wait/notify)机制下主动等待条件满足后获取通知的状态,处于running(OS意义下)状态的线程可主动调用object.wait或者sleep,或者join(join内部调用的是sleep,所以可看成sleep的一种)进入WAITING状态。
- JVM层面进程状态与OS层面进程状态对比
- 导致线程阻塞(OS意义下的阻塞waiting状态)的原因 线程阻塞的特点 线程放弃CPU的使用,暂停运行。只有等阻塞原因消除后回复运行;或是被其他线程中断导致该线程退出阻塞状态,同时跑出InterruptedException. 线程阻塞的状态包括 BLOCKED状态 无法获取同步锁 :synchronic WAITING状态(TIMED_WAITING状态) 不满足运行条件 :wait/notify、sleep RUNNABLE状态 正在JVM中执行,占用某个资源 :阻塞式 I/O 操作 线程阻塞的原因 (1)Thread.sleep(int millsecond) 调用 sleep 的线程会在一定时间内将 CPU 资源给其他线程执行,超过睡眠事件后唤醒。与是否持有同步锁无关。进程处于 TIMED_WAITING 状态 (2)线程执行一段同步代码(Synchronic)代码,但无法获取同步锁:同步锁用于实现线程同步执行,未获得同步锁而无法进入同步块的线程处于 BLOCKED 状态 (3)线程对象调用 wait 方法,进入同步块的线程发现运行条件不满足,此时会释放锁,并释放CPU,等待其他线程norify。此时线程处于 WAITING 状态 (4)执行阻塞式I/O操作,等待相关I/O设备(如键盘、网卡等),为了节省CPU资源,释放CPU。此时线程处于RUNNABLE状态。
(2)Java线程 状态转换
“Java 线程状态的改变通常只与自身显式引入的机制有关。如果 JVM 中的线程状态发生改变了,通常是自身机制引发的。比如 synchronize 机制有可能让线程进入BLOCKED 状态,sleep,wait等方法则可能让其进入 WATING 之类的状态。
1.2)线程控制方法
JVM充分地利用现代多核处理器的强大性能。采用异步调用线程,提高使用性能,缺点就是会造成线程不安全。为了保证线程安全性,即确保Java内存模型的可见性、原子性和有序性。Java主要通过volatile、synchronized实现线程安全。
(1.2.1)Synchronized
synchronized 规定了同一个时刻只允许一条线程可以进入临界区(互斥性),同时还保证了共享变量的内存可见性。此规则决定了持有同一个对象锁的多个同步块只能串行执行。 Java中的每个对象都可以为锁。
- 普通同步方法,锁是当前实例对象。
- 静态同步方法,锁是当前类的class对象。
- 同步代码块,锁是括号中的对象。
synchronized 是应用于同步问题的人工线程调度工具。Java中的每个对象都有一个监视器,来监测并发代码的重入。在非多线程编码时该监视器不发挥作用,反之如果在synchronized 范围内(线程进入同步块),监视器发挥作用,线程获得内置锁。内置锁是一个互斥锁,以为着最多只有一个线程能够获取该锁。这个锁由JVM自动获取和释放,线程进入synchronized方法时获取该对象的锁,synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁。这里也体现了用synchronized来加锁的1个好处,方法抛异常的时候,锁仍然可以由JVM来自动释放。 wait/notify必须存在于synchronized块中。并且,这三个关键字针对的是同一个监视器(某个对象的监视器)。 当某个线程wait之后,其他执行该同步快的线程可以进入该同步块执行。 当某个线程并不持有监视器的使用权时(如上图中5的状态,即脱离同步块)去wait或notify,会抛出java.lang.IllegalMonitorStateException。 在synchronized块中去调用另一个对象的wait/notify,因为不同对象的监视器不同,同样会抛出此异常。 锁的内部机制:从偏向锁到重量级锁 1. 对象头和monitor Java对象在内存中的存储结构主要有一下三个部分:
- 对象头
- 实例数据
- 填充数据
当创建一个对象时LockObject时,对象的Markword 存储锁的相关信息,包括指向轻量级锁指针、指向重量级锁指针、偏向线程ID 等。
monitor是线程私有的数据结构,每一个线程都有一个可用monitor列表,同时还有一个全局的可用列表,先来看monitor的内部
- Owner:初始时为NULL表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
- EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor失败的线程。
- RcThis:表示blocked或waiting在该monitor上的所有线程的个数。
- Nest:用来实现重入锁的计数。
- HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
- Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。
在 java 虚拟机中,线程一旦进入到被synchronized修饰的方法或代码块时,指定的锁对象通过某些操作将对象头中的LockWord指向monitor 的起始地址与之关联,同时monitor 中的Owner存放拥有该锁的线程的唯一标识,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码。
2. 偏向锁 当线程执行到临界区(critical section)时,此时会利用CAS(Compare and Swap)操作,将线程ID插入到Markword中,同时修改偏向锁的标志位。 此时偏向锁标志位为1。 偏向锁是jdk1.6引入的一项锁优化,其中的“偏”是偏心的偏。它的意思就是说,这个锁会偏向于第一个获得它的线程,在接下来的执行过程中,假如该锁没有被其他线程所获取,没有其他线程来竞争该锁,那么持有偏向锁的线程将永远不需要进行同步操作。 也就是说: 在此线程之后的执行过程中,如果再次进入或者退出同一段同步块代码,并不再需要去进行加锁或者解锁操作,而是会做以下的步骤: Load-and-test,也就是简单判断一下当前线程id是否与Markword当中的线程id是否一致. 如果一致,则说明此线程已经成功获得了锁,继续执行下面的代码. 如果不一致,则要检查一下对象是否还是可偏向,即“是否偏向锁”标志位的值。 如果还未偏向,则利用CAS操作来竞争锁,也即是第一次获取锁时的操作。 如果此对象已经偏向了,并且不是偏向自己,则说明存在了竞争。此时可能就要根据另外线程的情况,可能是重新偏向,也有可能是做偏向撤销,但大部分情况下就是升级成轻量级锁了。 即偏向锁是针对于一个线程而言的,线程获得锁之后就不会进行解锁操作,节省了很多开销。为什么要这样做呢?因为经验表明,其实大部分情况下,都会是同一个线程进入同一块同步代码块的。这也是为什么会有偏向锁出现的原因。在Jdk1.6中,偏向锁的开关是默认开启的,适用于只有一个线程访问同步块的场景。 下述代码中,当线程访问同步方法method1时,会在对象头(SynchronizedTest.class对象的对象头)和栈帧的锁记录中存储锁偏向的线程ID,下次该线程在进入method2,只需要判断对象头存储的线程ID是否为当前线程,而不需要进行CAS操作进行加锁和解锁(因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟)。
public class SynchronizedTest {
private static Object lock = new Object();
public static void main(String[] args) {
method1();
method2();
}
synchronized static void method1() {}
synchronized static void method2() {}
}
3. 轻量级锁 当出现有两个线程来竞争锁的话,那么偏向锁就失效了,此时锁就会膨胀,升级为轻量级锁。锁撤销升级为轻量级锁之后,那么对象的Markword也会进行相应的的变化。下面先简单描述下锁撤销之后,升级为轻量级锁的过程:
- 线程在自己的栈桢中创建锁记录 LockRecord。
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
- 将锁记录中的Owner指针指向锁对象。
- 将锁对象的对象头的MarkWord替换为指向锁记录的指针。
轻量级锁主要是自旋锁。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗cpu的,就相当于在执行一个啥也没有的for循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短很短的时间就能够获得锁了。自旋锁有一些问题: (1)如果同步代码块执行的很慢,需要消耗大量的时间,那么这个时侯,其他线程在原地等待空消耗cpu。 (2)本来一个线程把锁释放之后,当前线程是能够获得锁的,但是假如这个时候有好几个线程都在竞争这个锁的话,那么有可能当前线程会获取不到锁,还得原地等待继续空循环消耗cup,甚至有可能一直获取不到锁。 基于这个问题,我们必须给线程空循环设置一个次数,当线程超过了这个次数,我们就认为,继续使用自旋锁就不适合了,此时锁会再次膨胀,升级为重量级锁。
4. 重量级锁 轻量级锁膨胀之后,就升级为重量级锁了。重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁。 主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。 这就是说为什么重量级线程开销很大的。互斥锁(重量级锁)也称为阻塞同步、悲观锁 因此可做个总结: 线程可以通过两种方式锁住一个对象:
- 通过膨胀一个处于无锁状态(状态位001)的对象获得该对象的锁;
- 对象处于膨胀状态(状态位00),但LockWord指向的monitor的Owner字段为NULL,则可以直接通过CAS原子指令尝试将Owner设置为自己的标识来获得锁。
获取锁(monitorenter)的大概过程:
- 对象处于无锁状态时(LockWord的值为hashCode等,状态位为001),线程首先从monitor列表中取得一个空闲的monitor,初始化Nest和Owner值为1和线程标识,一旦monitor准备好,通过CAS替换monitor起始地址到LockWord进行膨胀。如果存在其它线程竞争锁的情况而导致CAS失败,则回到monitorenter重新开始获取锁的过程即可。
- 对象已经膨胀,monitor中的Owner指向当前线程,这是重入锁的情况(reentrant),将Nest加1,不需要CAS操作,效率高。
- 对象已经膨胀,monitor中的Owner为NULL,此时多个线程通过CAS指令试图将Owner设置为自己的标识获得锁,竞争失败的线程则进入第4种情况。
- 对象已经膨胀,同时Owner指向别的线程,在调用操作系统的重量级的互斥锁之前自旋一定的次数,当达到一定的次数如果仍然没有获得锁,则开始准备进入阻塞状态,将rfThis值原子加1,由于在加1的过程中可能被其它线程破坏对象和monitor之间的联系,所以在加1后需要再进行一次比较确保lock word的值没有被改变,当发现被改变后则要重新进行monitorenter过程。同时再一次观察Owner是否为NULL,如果是则调用CAS参与竞争锁,锁竞争失败则进入到阻塞状态。
释放锁(monitorexit)的大概过程:
- 检查该对象是否处于膨胀状态并且该线程是这个锁的拥有者,如果发现不对则抛出异常。
- 检查Nest字段是否大于1,如果大于1则简单的将Nest减1并继续拥有锁,如果等于1,则进入到步骤3。
- 检查rfThis是否大于0,设置Owner为NULL然后唤醒一个正在阻塞或等待的线程再一次试图获取锁,如果等于0则进入到步骤4。
- 缩小(deflate)一个对象,通过将对象的LockWord置换回原来的HashCode等值来解除和monitor之间的关联来释放锁,同时将monitor放回到线程私有的可用monitor列表。
重入锁 & 非重入锁 可重入锁指同一个线程可以再次获得之前已经获得的锁,避免产生死锁。 当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。 下面为可重入锁与非可重入锁的实现区别 不可重入锁
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();
}
}
可重入锁 重入锁的一种实现方法是为每个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待;而如果同一个线程再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。
public class Lock{
boolean isLocked = false;
Thread lockedBy = null;
int lockedCount = 0;
public synchronized void lock()
throws InterruptedException{
Thread thread = Thread.currentThread();
while(isLocked && lockedBy != thread){
wait();
}
isLocked = true;
lockedCount++;
lockedBy = thread;
}
public synchronized void unlock(){
if(Thread.currentThread() == this.lockedBy){
lockedCount--;
if(lockedCount == 0){
isLocked = false;
notify();
}
}
}
}
lockBy:保存已经获得锁实例的线程,在lock()判断调用lock的线程是否已经获得当前锁实例,如果已经获得锁,则直接跳过while,无需等待。 lockCount:记录同一个线程重复对一个锁对象加锁的次数。否则,一次unlock就会解除所有锁,即使这个锁实例已经加锁多次了。 两种锁举例
public class Count{
Lock lock = new Lock();
public void print(){
lock.lock();
doAdd();
lock.unlock();
}
public void doAdd(){
lock.lock();
//do something
lock.unlock();
}
}
对于不可重入锁,当一个线程调用print()方法时,获得了锁,这时就无法再调用doAdd()方法,这时必须先释放锁才能调用,所以称这种锁为不可重入锁,也叫自旋锁。 对于可重入锁,可重入就意味着:线程可以进入任何一个它已经拥有的锁所同步着的代码块。 第一个线程执行print()方法,得到了锁,使lockedBy等于当前线程,也就是说,执行的这个方法的线程获得了这个锁,执行add()方法时,同样要先获得锁,因不满足while循环的条件,也就是不等待,继续进行,将此时的lockedCount变量,也就是当前获得锁的数量加一,当释放了所有的锁,才执行notify()。如果在执行这个方法时,有第二个线程想要执行这个方法,因为lockedBy不等于第二个线程,导致这个线程进入了循环,也就是等待,不断执行wait()方法。只有当第一个线程释放了所有的锁(一共两个锁:print方法一个锁+add方法一个锁),执行了notify()方法,第二个线程才得以跳出循环,继续执行。 java中常用的可重入锁
- synchronized
- java.util.concurrent.locks.ReentrantLock
注意: 这里要区别,同一个对象的多方法都加入synchronized关键字时,线程A 访问 (synchronized)object.A,线程B 访问 (synchronized)object.B时,必须等线程A访问完A,线程B才能访问B;此结论同样适用于对于object中使用synchronized(this)同步代码块的场景;synchronized锁定的都是当前对象!
lock 机制(单独使用)实现线程竞争 在多线程环境下,synchronized块中的方法获取了lock实例的monitor,如果实例相同,那么只有一个线程(通过竞争获取到lock实例的线程)能执行该块内容。
//通过lock锁定
public class Thread1 implements Runnable {
Object lock;
public void run() {
synchronized(lock){
..do something
}
}
}
//直接用于方法
public class Thread1 implements Runnable {
public synchronized void run() {
..do something
}
}
synchronized使用 (1)同步方法 synchronized关键字修饰的方法 由于java的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。 此时该内置锁为对象锁/类锁。我们知道,类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。
public synchronized void save(){}
注: synchronized关键字也可以修饰静态方法,此时如果调用该静态方法,将会锁住整个类 (1.1)对象锁 对象锁是用于对象实例方法,或者一个对象实例上的 (1.2)类锁 类锁是用于类的静态方法或者一个类的class对象上的 对象锁是用来控制实例方法之间的同步,类锁是用来控制静态方法(或静态变量互斥体)之间的同步。其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的。我们都知道,java类可能会有很多个对象,但是只有1个Class对象,也就是说类的不同实例之间共享该类的Class对象。Class对象其实也仅仅是1个java对象,只不过有点特殊而已。由于每个java对象都有1个互斥锁,而类的静态方法是需要Class对象。所以所谓的类锁,不过是Class对象的锁而已。获取类的Class对象有好几种,最简单的就是MyClass.class的方式。 (1.3)对象锁与类锁的区别 synchronized是对类的当前实例进行加锁,防止其他线程同时访问该类的该实例的所有synchronized块,注意这里是“类的当前实例”,类的两个不同实例就没有这种约束了。那么static synchronized恰好就是要控制类的所有实例的访问了,static synchronized是限制线程同时访问jvm中该类的所有实例同时访问对应的代码快。实际上,在类中某方法或某代码块中有 synchronized,那么在生成一个该类实例后,该类也就有一个监视快,放置线程并发访问该实例synchronized保护快,而static synchronized则是所有该类的实例公用一个监视快了,也就是两个的区别了,也就是synchronized相当于this.synchronized,而staticsynchronized相当于Something.synchronized.
pulbic class Something(){
public synchronized void isSyncA(){}
public synchronized voidisSyncB(){}
public static synchronizedvoid cSyncA(){}
public static synchronizedvoid cSyncB(){}
}
那么,假如有Something类的两个实例a与b,那么下列组方法何以被1个以上线程同时访问呢
a. x.isSyncA()与x.isSyncB()
b. x.isSyncA()与y.isSyncA()
c. x.cSyncA()与y.cSyncB()
d. x.isSyncA()与Something.cSyncA()
a,都是对同一个实例的synchronized域访问,因此不能被同时访问
b,是针对不同实例的,因此可以同时被访问
c,因为是staticsynchronized,所以不同实例之间仍然会被限制,相当于Something.isSyncA()与 Something.isSyncB()了,因此不能被同时访问。
d,是可以被同时访问的,答案理由是synchronzied的是实例方法与synchronzied的类方法由于锁定(lock)不同的原因。
个人分析也就是synchronized 与static synchronized 相当于两帮派,各自管各自,相互之间就无约束了,可以被同时访问。后面一部分将详细分析synchronzied是怎么样实现的。
结论:
- synchronized static是某个类的范围,synchronized static cSync{}防止多个线程同时访问这个类中的synchronized static 方法。它可以对类的所有对象实例起作用。
- synchronized 是某实例的范围,synchronized isSync(){}防止多个线程同时访问这个实例中的synchronized 方法。
- 类锁和对象锁不是同1个东西,一个是类的Class对象的锁,一个是类的实例的锁。也就是说:1个线程访问静态synchronized的时候,允许另一个线程访问对象的实例synchronized方法。反过来也是成立的,因为他们需要的锁是不同的。
(2)同步代码块 synchronized关键字修饰的语句块 但用Synchronized修饰同步方法有缺陷: 当某个线程进入同步方法获得对象锁,那么其他线程访问这里对象的同步方法时,必须等待或者阻塞,这对高并发的系统是致命的,这很容易导致系统的崩溃。如果某个线程在同步方法里面发生了死循环,那么它就永远不会释放这个对象锁,那么其他线程就要永远的等待。这是一个致命的问题。 因此用synchronized修饰代码块,缩小同步范围,减少了风险。 因此采用同步代码块,被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。 代码如:
synchronized(object){
}
注:同步是一种高开销的操作,因此应该尽量减少同步的内容。 通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。
public class TestSynchronized
{
public void test1()
{
synchronized(this)
{
int i = 5;
while( i-- > 0)
{
System.out.println(Thread.currentThread().getName() + " : " + i);
try
{
Thread.sleep(500);
}
catch (InterruptedException ie)
{
}
}
}
}
public synchronized void test2()
{
int i = 5;
while( i-- > 0)
{
System.out.println(Thread.currentThread().getName() + " : " + i);
try
{
Thread.sleep(500);
}
catch (InterruptedException ie)
{
}
}
}
public static void main(String[] args)
{
final TestSynchronized myt2 = new TestSynchronized();
Thread test1 = new Thread( new Runnable() { public void run() { myt2.test1(); } }, "test1" );
Thread test2 = new Thread( new Runnable() { public void run() { myt2.test2(); } }, "test2" );
test1.start();;
test2.start();
// TestRunnable tr=new TestRunnable();
// Thread test3=new Thread(tr);
// test3.start();
}
}
执行结果
test2 : 4
test2 : 3
test2 : 2
test2 : 1
test2 : 0
test1 : 4
test1 : 3
test1 : 2
test1 : 1
test1 : 0
上述的代码,第一个方法时用了同步代码块的方式进行同步,传入的对象实例是this,表明是当前对象,当然,如果需要同步其他对象实例,也不可传入其他对象的实例;第二个方法是修饰方法的方式进行同步。因为第一个同步代码块传入的this,所以两个同步代码所需要获得的对象锁都是同一个对象锁,下面main方法时分别开启两个线程,分别调用test1和test2方法,那么两个线程都需要获得该对象锁,另一个线程必须等待。上面也给出了运行的结果可以看到:直到test2线程执行完毕,释放掉锁,test1线程才开始执行。(可能这个结果有人会有疑问,代码里面明明是先开启test1线程,为什么先执行的是test2呢?这是因为java编译器在编译成字节码的时候,会对代码进行一个重排序,也就是说,编译器会根据实际情况对代码进行一个合理的排序,编译前代码写在前面,在编译后的字节码不一定排在前面,所以这种运行结果是正常的, 这里是题外话,最主要是检验synchronized的用法的正确性) synchronized同时修饰静态方法和实例方法,但是运行结果是交替进行的,这证明了类锁和对象锁是两个不一样的锁,控制着不同的区域,它们是互不干扰的。同样,线程获得对象锁的同时,也可以获得该类锁,即同时获得两个锁,这是允许的。 一个类的对象锁和另一个类的对象锁是没有关联的,当一个线程获得A类的对象锁时,它同时也可以获得B类的对象锁。
wait/notify 机制实现线程协作 wait/notify机制:在Java中,可以通过配合调用Object对象的wait()方法和notify()方法或notifyAll()方法来实现线程间的通信。 由于 wait()、notify/notifyAll() 在synchronized 代码块执行,说明当前线程一定是获取了锁的。 当线程执行wait()方法时候,会将当前进程阻塞,释放当前的锁,然后让出CPU,进入等待状态。(直到接到通知或被中断为止) 只有当 notify/notifyAll() 被执行时候,才会唤醒一个或多个正处于等待状态的线程,从wait()方法中继续往下执行。 要注意
- notify唤醒阻塞的线程后,线程会接着上次的执行继续往下执行。
- wait/notify必须在同步方法或同步快中调用。wait()方法释放当前线程的锁,因此如果当前线程没有持有适当的锁,则抛出IllegalMonitorStateException异常。notify()方法调用前,线程也必须要获得该对象的对象级别锁,的如果调用notify()时没有持有适当的锁,也会抛出IllegalMonitorStateException。
- notify与notifyall区别与联系 notify 与 notifyall 都是用于唤醒被 wait 的线程 notify 调用后,如果有多个线程等待,则线程规划器任意挑选出其中一个wait()状态的线程来发出通知,并使它等待获取该对象的对象锁。但不惊动其他同样在等待被该对象notify的线程们。当第一个获得了该对象锁的wait线程运行完毕以后,它会释放掉该对象锁,此时如果该对象没有再次使用notify语句,则即便该对象已经空闲,其他wait状态等待的线程由于没有得到该对象的通知,会继续阻塞在wait状态,直到这个对象发出一个notify或notifyAll。 notifyAll使所有原来在该对象上wait的线程统统退出wait的状态(即全部被唤醒,不再等待notify或notifyAll,但由于此时还没有获取到该对象锁,因此还不能继续往下执行),变成等待获取该对象上的锁,一旦该对象锁被释放(notifyAll线程退出调用了notifyAll的synchronized代码块的时候),他们就会去竞争。如果其中一个线程获得了该对象锁,它就会继续往下执行,在它退出synchronized代码块,释放锁后,其他的已经被唤醒的线程将会继续竞争获取该锁,一直进行下去,直到所有被唤醒的线程都执行完毕。
- notify后,当前线程不会马上释放该对象锁,wait所在的线程并不能马上获取该对象锁,要等到程序退出synchronized代码块后,当前线程才会释放锁,wait所在的线程也才可以通过竞争获取该对象锁
典型案例:生产者-消费者问题 两个进程共享一个公共的固定大小的缓冲区。其中一个是生产者,用于将消息放入缓冲区;另外一个是消费者,用于从缓冲区中取出消息。问题出现在当缓冲区已经满了,而此时生产者还想向其中放入一个新的数据项的情形,其解决方法是让生产者此时进行休眠,等待消费者从缓冲区中取走了一个或者多个数据后再去唤醒它。同样地,当缓冲区已经空了,而消费者还想去取消息,此时也可以让消费者进行休眠,等待生产者放入一个或者多个数据时再唤醒它。
/**
* 生产者生产出来的产品交给店员
*/
public synchronized void produce()
{
if(this.product >= MAX_PRODUCT)
{
try
{
wait();
System.out.println("产品已满,请稍候再生产");
}
catch(InterruptedException e)
{
e.printStackTrace();
}
return;
}
this.product++;
System.out.println("生产者生产第" + this.product + "个产品.");
notifyAll(); //通知等待区的消费者可以取出产品了
}
/**
* 消费者从店员取产品
*/
public synchronized void consume()
{
if(this.product <= MIN_PRODUCT)
{
try
{
wait();
System.out.println("缺货,稍候再取");
}
catch (InterruptedException e)
{
e.printStackTrace();
}
return;
}
System.out.println("消费者取走了第" + this.product + "个产品.");
this.product--;
notifyAll(); //通知等待去的生产者可以生产产品了
}
(1.2.2)Volatile
1.Java多线程内存模式 与 重排序
在JAVA多线程环境下,对于每个Java线程除了共享的虚拟机栈外和Java堆之外,还存在一个独立私有的工作内存,工作内存存放主存中变量的值的拷贝。每个线程独立运行,彼此之间都不可见,线程的私有堆内存中保留了一份主内存的拷贝,只有在特定需求的情况下才会与主存做交互(复制/刷新)。
当数据从主内存复制到工作存储时,必须出现两个动作:第一,由主内存执行的读(read)操作;第二,由工作内存执行的相应的load操作;当数据从工作内存拷贝到主内存时,也出现两个操作:第一个,由工作内存执行的存储(store)操作;第二,由主内存执行的相应的写(write)操作
每一个操作都是原子的,即执行期间不会被中断。
对于普通变量,一个线程中更新的值,不能马上反应在其他变量中。
如果需要在其他线程中立即可见,需要使用 volatile 关键字。
在有些场景下多线程访问程序变量会表现出与程序制定的顺序不一样。因为编译器可以以优化的名义改变每个独立线程的顺序,从而使处理器不按原来的顺序执行线程。一个Java程序在从源代码到最终实际执行的指令序列之间,会经历一系列的重排序过程。
对于多线程共享同一内存区域这一情况,使得每个线程不知道其他线程对数据做了怎样的修改(数据修改位于线程的私有内存中,具有不可见性),从而导致执行结果不正确。因此必须要解决这一同步问题。
2.volatile原理
对于非volatile变量进行读写时,每个写成先从主存拷贝变量到线程缓存中,执行完操作再保存到主存中。需要进行load/save操作。
而volatile变量保证每次读写变量都是不经过缓存而是直接从内存读写数据。省去了load/save操作。volatile变量不会将对该变量的操作与其他内存操作一起重排序,能及时更新到主存;且因该变量存储在主存上,所以总会返回最新写入的值。
因此volatile定义的变量具有以下特性:
- 保证此变量对所有的线程的可见性。 当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存更新。因此使用volatile修饰域相当于告诉JVM该域会被其他线程更新,volatile修饰域一旦改变,相当于告诉所有其他线程该域的变化。但非volatile变量的值在线程间传递均需要通过主内存完成,看到的数据可能不是最新的数据。
- 禁止指令重排序优化。 有volatile修饰的变量,赋值后多执行了一个“load and save”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置)
- 性能较低 volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不乱序执行。
- 轻量级sychronized 在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
3.volatile实例
class Bank {
//需要同步的变量加上volatile
private volatile int account = 100;
public int getAccount() {
return account;
}
//这里不再需要synchronized
public void save(int money) {
account += money;
}
}
4. Volatile、Synchronized 与 Final 对比
- final不可变 作用于类、方法、成员变量、局部变量。初始化完成后的不可变对象,其它线程可见。常量不会改变不会因为其它线程产生影响。Final修饰的引用类型的地址不变,同时需要保证引用类型各个成员和操作的线程安全问题。因为引用类型成员可能是可变的。
- synchronized同步 作用域代码块、方法上。通过线程互斥,同一时间的同样操作只允许一个线程操作。通过字节码指令实现。
- Volatile 修饰域
- volatile 修饰的变量的变化保证对其它线程立即可见。 volatile变量的写,先发生于读。每次使用volatile修饰的变量个线程都会刷新保证变量一致性。但同步之前各线程可能仍有操作。如:各个根据volatile变量初始值分别进行一些列操作,然后再同步写赋值。每个线程的操作有先后,当一个最早的线程给线程赋值时,其它线程同步。但这时其它线程可能根据初始值做了改变,同步的结果导致其它线程工作结果丢失。根据volatile的语意使用条件:运算结果不依赖变量的当前值。
- volatile禁止指令重排优化。 这个语意导致写操作会慢一些。因为读操作跟这个没关系。
(1.2.3)ReentrantLock
在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。 lock: 在java.util.concurrent包内。共有三个实现: //ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。
- ReentrantLock
- ReentrantReadWriteLock.ReadLock
- ReentrantReadWriteLock.WriteLock
主要目的是和synchronized一样, 两者都是为了解决同步问题,处理资源争端而产生的技术。功能类似但有一些区别。 区别如下:
- lock更灵活,可以自由定义多把锁的枷锁解锁顺序(synchronized要按照先加的后解顺序)
- 提供多种加锁方案,lock 阻塞式, trylock 无阻塞式, lockInterruptily 可打断式, 还有trylock的带超时时间版本。
- 本质上和监视器锁(即synchronized是一样的)
- 能力越大,责任越大,必须控制好加锁和解锁,否则会导致灾难。
- 和Condition类的结合。
- 性能更高
ReenreantLock类实现原理 轻松学习java可重入锁(ReentrantLock)的实现原理 ReentrantLock支持两种获取锁的方式,一种是公平模型,一种是非公平模型。 ReentrantLock结构如下
| volatile int state | 表示临界资源占有状态 |
|---|---|
| Thread | 正在执行的线程 |
| Node 双向队列 | 处于等待阻塞的节点 |
- 公平锁模型
- 初始化时,state=0,表示没有线程占用资源。线程A请求锁。
- 线程A 获得锁,state原子性+1 并执行任务。线程B请求锁。
- 线程B无法获得锁,生成节点进行排队(Node队列)。线程A再次请求锁。
- 此时的线程A不需要排队,直接得到锁,执行任务,state原子性+1(可重入锁:一个线程在获取了锁之后,再次去获取了同一个锁,这时候仅仅是把状态值进行累加)。
- 线程A释放了一次锁,则state原子性 -1,只有当线程A 将锁全部释放,state=0时,其他线程才有机会获取锁,此时会通知队列唤醒 线程B节点,使线程 B 可以参与竞争。
- 若线程B竞争获得锁,则对应结点从队列中删除。
- 不公平锁模型 当线程A执行完之后,要唤醒线程B是需要时间的,而且线程B醒来后还要再次竞争锁,所以如果在切换过程当中,来了一个线程C,那么线程C是有可能获取到锁的,如果C获取到了锁,B就只能继续乖乖休眠了。
即公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序(队列的先后顺序)来依次获得锁。而不公平锁则不用按照申请锁的时间顺序获得锁。synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
ReenreantLock类的常用方法
- ReentrantLock() : 创建一个ReentrantLock实例
- lock() : 获得锁
- unlock() : 释放锁
class Bank {
private int account = 100;
//需要声明这个锁
private Lock lock = new ReentrantLock();
public int getAccount() {
return account;
}
//这里不再需要synchronized
public void save(int money) {
lock.lock();
try{
account += money;
}finally{
lock.unlock();
}
}
}
ReenreantLock & Synchronized 的选择
| 比较类型 | Synchronized | ReenreantLock |
|---|---|---|
| 锁的实现 | JVM | JDK |
| 等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情 | 不可中断 | 可中断 |
| 公平锁:公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。 | 非公平 | 公平/非公平(默认) |
| 除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。 |
总结:Synchronized & ReentrantLock & Volatile 区别 (1)Synchronized &Volatile 区别 AbstractQueuedSynchronizer通过构造一个基于阻塞的CLH队列容纳所有的阻塞线程,而对该队列的操作均通过Lock-Free(CAS)操作,但对已经获得锁的线程而言,ReentrantLock实现了偏向锁的功能。 synchronized的底层也是一个基于CAS操作的等待队列,但JVM实现的更精细,把等待队列分为ContentionList和EntryList,目的是为了降低线程的出列速度;当然也实现了偏向锁,从数据结构来说二者设计没有本质区别。但synchronized还实现了自旋锁,并针对不同的系统和硬件体系进行了优化,而Lock则完全依靠系统阻塞挂起等待线程。 当然Lock比synchronized更适合在应用层扩展,可以继承AbstractQueuedSynchronizer定义各种实现,比如实现读写锁(ReadWriteLock),公平或不公平锁;同时,Lock对应的Condition也比wait/notify要方便的多、灵活的多。
(2)Synchronized & ReentrantLock 区别 volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。 volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的 volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性 volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。 volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化
1.3)基本线程类
(1.3.1)Thread 类
Thread类实现了Runnable接口,在Thread类中,有一些比较关键的属性
public class Thread implements Runnable{
private char name[];//表示Thread名字,可以通过Thread构造器中的参数指定线程的名字
private int priority;//线程的优先级(最大值为10,最小值为1,默认为5)
// 守护线程和用户线程的区别在于:守护线程依赖于创建它的线程,而用户线程则不依赖。举个简单的例子:如果在main线程中创建了一个守护线程,当main方法运行完毕之后,守护线程也会随着消亡。而用户线程则不会,用户线程会一直运行直到其运行完毕。在JVM中,像垃圾收集器线程就是守护线程。
private boolean daemon = false;//该线程是否为守护线程
private Runnable target;//要执行的任务
}
Thread类常用的方法如下
// start() 用来启动一个线程,实现多线程,当调用start方法后,系统会开启一个新线程用来执行用户定义的子任务,并为响应线程分配资源。这时线程处于就绪状态,但并没有运行,一旦得到cpu时间片,就开始执行run方法(run()称为线程体,包含要执行这个线程的内容,run()方法运行结束则线程终止)
public static Thread.start()
// run()方法是不需要用户来调用的,当通过start方法启动一个线程之后,当线程获得了CPU执行时间,便进入run方法体去执行具体的任务。注意,继承Thread类必须重写run方法,在run方法中定义具体要执行的任务。
public static Thread.run()
// 当前线程可转让cpu控制权,让别的就绪状态线程运行(切换)
// 调用yield方法会让当前线程交出CPU权限,让CPU去执行其他的线程。它跟sleep方法类似,同样不会释放锁。但是yield不能控制具体的交出CPU的时间,另外,yield方法只能让拥有相同优先级的线程有获取CPU执行时间的机会。
// 注意,调用yield方法并不会让线程进入阻塞状态,而是让线程重回就绪状态,它只需要等待重新获取CPU执行时间,这一点是和sleep方法不一样的。
public static Thread.yield()
// sleep相当于让线程睡眠,交出CPU,让CPU去执行其他的任务。
// 但是有一点要非常注意,sleep方法不会释放锁(相当于一直持有该对象的锁),也就是说如果当前线程持有对某个对象的锁,则即使调用sleep方法,其他线程也无法访问这个对象。
// 还有一点要注意,如果调用了sleep方法,必须捕获InterruptedException异常或者将该异常向上层抛出。当线程睡眠时间满后,不一定会立即得到执行,因为此时可能CPU正在执行其他的任务。所以说调用sleep方法相当于让线程进入阻塞状态。
sleep(long millis) //参数为毫秒
sleep(long millis,int nanoseconds) //第一参数为毫秒,第二个参数为纳秒
// 在一个线程中调用other.join(),将等待other执行完后才继续本线程。
// 假如在main线程中,调用thread.join方法,则main方法会等待thread线程执行完毕或者等待一定的时间。如果调用的是无参join方法,则等待thread执行完毕,如果调用的是指定了时间参数的join方法,则等待一定的时间。
join()
join(long millis) //参数为毫秒
join(long millis,int nanoseconds) //第一参数为毫秒,第二个参数为纳秒
// interrupt()是Thread类的一个实例方法,用于中断本线程。这个方法被调用时,会立即将线程的中断标志设置为“true”。所以当中断处于“阻塞状态”的线程时,由于处于阻塞状态,中断标记会被设置为“false”,抛出一个 InterruptedException。所以我们在线程的循环外捕获这个异常,就可以退出线程了。
// interrupt()并不会中断处于“运行状态”的线程,它会把线程的“中断标记”设置为true,所以我们可以不断通过isInterrupted()来检测中断标记,从而在调用了interrupt()后终止线程,这也是通常我们对interrupt()的用法。
public interrupte()
Java中断机制
- Java 中断机制介绍 java中的线程中断机制是一种协作机制。线程会不时地检测中断标识位,以判断线程是否应该被中断(中断标识值是否为true)。 Java提供了中断机制,Threaf类下有3个重要的方法
- public void interrupt();//每个线程都有个boolean类型的中断状态。当使用Thread的interrupt()方法时,线程的中断状态会被设置为true。
- public boolean isInterrupted();//判断线程是否被中断
- public static boolean interrupted(); // 清除中断标志,并返回原状态 当一个线程中断另一个线程时,被中断的线程不一定要立即停止正在做的事情。相反,中断是礼貌地请求另一个线程在它愿意并且方便的时候停止它正在做的事情。interrupt方法,就是告诉线程,我需要中断你,该方法调用之后,线程并不会立刻终止,而是在合适的时机终止。什么时机呢?Java的处理判定机制为: 机制一:如果该线程处在可中断状态下,(例如Thread.sleep(), Thread.join()或 Object.wait()),那么该线程会立即被唤醒,同时会收到一个InterruptedException,如果是阻塞在io上,对应的资源会被关闭。 机制二:如果该线程处在不可中断状态下,即没有调用上述api,处于运行时的进程。那么java只是设置一下该线程的interrupt状态,其他事情都不会发生,如果该线程之后会调用行数阻塞API,那到时候线程会马会上跳出,并抛出InterruptedException,接下来的事情就跟第一种状况一致了。如果不会调用阻塞API,那么这个线程就会一直执行下去。在被中断线程中运行的代码以后可以轮询中断状态,看看它是否被请求停止正在做的事情。中断状态可以通过 Thread.isInterrupted()来读取,并且可以通过一个名为Thread.interrupted() 的操作读取和清除。
- 利用 中断机制 正确结束线程 综合线程处于“阻塞状态”和“运行状态”的终止方式,比较通用的终止线程的形式如下:
public class InterruptedExample {
public static void main(String[] args) throws Exception {
InterruptedExample interruptedExample = new InterruptedExample();
interruptedExample.start();
}
public void start() {
MyThread myThread = new MyThread();
myThread.start();
try {
//当Thread 处于 sleep 后处于阻塞状态,收到中断请求会跑出InterruptedException异常
Thread.sleep(3000);
myThread.cancel();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private class MyThread extends Thread{
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
// 线程循环执行打印一些信息,使用isInterrupted判断线程是否被中断,若中断则结束线程
System.out.println("test");
Thread.sleep(1000);
} catch (InterruptedException e) {
// 阻塞状态下的线程抛出异常后则会被终止
System.out.println("interrupt");
// 抛出InterruptedException后中断标志被清除(中断标志 重新设置为false)
// 标准做法是再次调用interrupt恢复中断,正确情景下为true
Thread.currentThread().interrupt();
}
}
System.out.println("stop");
}
public void cancel(){
//对线程调用interrupt()方法,不会真正中断正在运行的线程,
//只是发出一个请求,由线程在合适时候结束自己。
interrupt();
}
}
}
线程执行方法与状态的联系
Thread类实现案例
(1)继承Thread类,重写该类的run()方法,run方法的方法体代表了线程要完成的任务,因此run方法可称为执行体
(2)创建Thread子类的实例,即创建线程对象
(3)调用线程对象的start方法启动线程
package com.thread;
public class FirstThreadTest extends Thread{
int i = 0;
//重写run方法,run方法的方法体就是现场执行体
public void run()
{
for(;i<100;i++){
System.out.println(getName()+" "+i);
}
}
public static void main(String[] args)
{
for(int i = 0;i< 100;i++)
{
// 调用100次main主线程
System.out.println(Thread.currentThread().getName()+" : "+i);
if(i==20)
{
// 当主线程调用到20时,执行100次子线程-1,子线程-2
new FirstThreadTest().start();
new FirstThreadTest().start();
}
}
}
}
实现结果
main : 18
main : 19
main : 20
Thread-0 0
Thread-0 1
main : 21
Thread-0 2
Thread-1 0
main : 22
main : 23
Thread-1 1
Thread-0 3
(1.3.2)Runnable 接口
(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。 (2)创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。 (3)调用线程对象的start()方法来启动该线程。
package com.thread;
public class RunnableThreadTest implements Runnable
{
private int i;
public void run()
{
for(i = 0;i <100;i++)
{
System.out.println(Thread.currentThread().getName()+" "+i);
}
}
public static void main(String[] args)
{
for(int i = 0;i < 100;i++)
{
System.out.println(Thread.currentThread().getName()+" "+i);
if(i==20)
{
RunnableThreadTest rtt = new RunnableThreadTest();
new Thread(rtt,"新线程1").start();
new Thread(rtt,"新线程2").start();
}
}
}
}
(1.3.3)Callable 接口
通过Callable和FutureTask创建线程 (1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。 (2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。 (3)使用FutureTask对象作为Thread对象的target创建并启动新线程。 (4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
package com.thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class CallableThreadTest implements Callable<Integer>
{
public static void main(String[] args)
{
// 创建Callable实现体的实例,使用FutureTask类包装Callable对象
CallableThreadTest ctt = new CallableThreadTest();
FutureTask<Integer> ft = new FutureTask<>(ctt);
for(int i = 0;i < 100;i++)
{
System.out.println(Thread.currentThread().getName()+" 的循环变量i的值"+i);
if(i==20)
{
//使用FutureTask对象作为Thread对象的target创建并启动新线程
new Thread(ft,"有返回值的线程").start();
}
}
try
{
//调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
System.out.println("子线程的返回值:"+ft.get());
} catch (InterruptedException e)
{
e.printStackTrace();
} catch (ExecutionException e)
{
e.printStackTrace();
}
}
@Override
public Integer call() throws Exception
{
// call 方法即为线程的执行体,并且拥有返回值
int i = 0;
for(;i<100;i++)
{
System.out.println(Thread.currentThread().getName()+" "+i);
}
return i;
}
}
创建线程的三种方式及对比
| 创建方式 | 使用方式 | 优势 | 劣势 |
|---|---|---|---|
| Thread | 继承Thread类创建线程类,并重写run方法 | 编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。 | 线程类已经继承了Thread类,所以不能再继承其他父类。 |
| Runnable接口 | 实现Runnable接口创建线程类,并实现run方法 | 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。 | 编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。 |
| Callable接口 | 实现Callable接口创建线程类,并用FutureTask类包装Callable对象,并实现call方法 | 同上 | 同上 |
1.4)高级多线程控制类
Java1.5提供了一个非常高效实用的多线程包:java.util.concurrent, 提供了大量高级工具,可以帮助开发者编写高效、易维护、结构清晰的Java多线程程序。
(1.4.1)ThreadLocal类
用处:保存线程的独立变量。对一个线程类(继承自Thread) 当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,副本之间相互独立,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。常用于用户登录控制,如记录session信息。 实现:每个Thread都持有一个TreadLocalMap类型的变量(该类是一个轻量级的Map,功能与map一样,区别是桶里放的是entry而不是entry的链表。功能还是一个map。)以本身为key,以目标为value。 主要方法是get()和set(T a),set之后在map里维护一个threadLocal -> a,get时将a返回。ThreadLocal是一个特殊的容器。 ThreadLocal 类的常用方法
- ThreadLocal() : 创建一个线程本地变量
- get() : 返回此线程局部变量的当前线程副本中的值
- initialValue() : 返回此线程局部变量的当前线程的"初始值"
- set(T value) : 将此线程局部变量的当前线程副本中的值设置为value
public class Bank{
//使用ThreadLocal类管理共享变量account
private static ThreadLocal<Integer> account = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue(){
return 100;
}
};
public void save(int money){
account.set(account.get()+money);
}
public int getAccount(){
return account.get();
}
}
(1.4.2)原子类(AtomicInteger、AtomicBoolean……)
如果使用atomic wrapper class如atomicInteger,或者使用自己保证原子的操作,则等同于synchronized
//返回值为boolean
AtomicInteger.compareAndSet(int expect,int update)
该方法可用于实现乐观锁,考虑文中最初提到的如下场景:a给b付款10元,a扣了10元,b要加10元。此时c给b2元,但是b的加十元代码约为:
if(b.value.compareAndSet(old, value)){
return ;
}else{
//try again
// if that fails, rollback and log
}
AtomicReference 对于AtomicReference 来讲,也许对象会出现,属性丢失的情况,即oldObject == current,但是oldObject.getPropertyA != current.getPropertyA。 这时候,AtomicStampedReference就派上用场了。这也是一个很常用的思路,即加上版本号
(1.4.3)容器类
- BlockingQueue 阻塞队列。该类是java.util.concurrent包下的重要类,通过对Queue的学习可以得知,这个queue是单向队列,可以在队列头添加元素和在队尾删除或取出元素。类似于一个管 道,特别适用于先进先出策略的一些应用场景。普通的queue接口主要实现有PriorityQueue(优先队列)。 除了传统的queue功能(表格左边的两列)之外,还提供了阻塞接口put和take,带超时功能的阻塞接口offer和poll。put会在队列满的时候阻塞,直到有空间时被唤醒;take在队 列空的时候阻塞,直到有东西拿的时候才被唤醒。用于生产者-消费者模型尤其好用,堪称神器。
- ConcurrentHashMap 高效的线程安全哈希map。请对比hashTable , concurrentHashMap, HashMap
(1.4.4)Semaphore —— 控制并发线程数
简介 信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施, 它负责协调各个线程, 以保证它们能够正确、合理的使用公共资源。Semaphore分为单值和多值两种,前者只能被一个线程获得,后者可以被若干个线程获得。 信号量的特性如下: 在多线程对一个(多个)公共资源进行访问的场景下, 信号量是一个非负整数(表示可以并发访问公共资源的线程数),所有通过它的线程都会将该整数减一(可使用的公共资源数目-1),当该整数值为零时,所有试图通过它的线程都将处于等待状态。在信号量上我们定义两种操作: Wait(等待) 和 Release(释放)。 当一个线程调用Wait(等待)操作时,它要么通过然后将信号量减一(Semaphore>0);要么一直等下去(Semaphore<=0),直到信号量大于0或超时。Release(释放)实际上是在信号量上执行加操作,该操作之所以叫做“释放”是因为加操作实际上是释放了由信号量守护的公共资源。 在java中,还可以设置该信号量是否采用公平模式,如果以公平方式执行,则线程将会按到达的顺序(FIFO)执行,如果是非公平,则可以后请求的有可能排在队列的头部。 Java 使用
Semaphore(int permits, boolean fair)
//创建具有给定的许可数和给定的公平设置的Semaphore。
Semaphore当前在多线程环境下被扩放使用,操作系统的信号量是个很重要的概念,在进程控制方面都有应用。Java并发库Semaphore 可以很轻松完成信号量控制,Semaphore可以控制某个资源可被同时访问的个数,通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。比如在Windows下可以设置共享文件的最大客户端访问个数。 Semaphore实现的功能就类似厕所有5个坑,假如有10个人要上厕所,那么同时只能有多少个人去上厕所呢?同时只能有5个人能够占用,当5个人中 的任何一个人让开后,其中等待的另外5个人中又有一个人可以占用了。另外等待的5个人中可以是随机获得优先机会,也可以是按照先来后到的顺序获得机会,这取决于构造Semaphore对象时传入的参数选项。单个信号量的Semaphore对象可以实现互斥锁的功能,并且可以是由一个线程获得了“锁”,再由另一个线程释放“锁”,这可应用于死锁恢复的一些场合。 实现案例 模拟上述实例,创建一个 同一时间最多只有5个线程访问 的连接池。
package SemaPhore;
import java.util.Random;
import java.util.concurrent.*;
public class Test {
public static void main(String[] args) {
//线程池
ExecutorService executor = Executors.newCachedThreadPool();
//定义信号量,只能5个线程同时访问
final Semaphore semaphore = new Semaphore(5);
//模拟20个线程同时访问
for (int i = 0; i < 20; i++) {
final int NO = i;
Runnable runnable = new Runnable() {
public void run() {
try {
//获取许可
semaphore.acquire();
//availablePermits()指的是当前信号灯库中有多少个可以被使用
System.out.println("线程" + Thread.currentThread().getName() +"进入,当前已有" + (5-semaphore.availablePermits()) + "个并发");
System.out.println("index:"+NO);
Thread.sleep(new Random().nextInt(1000)*10);
System.out.println("线程" + Thread.currentThread().getName() + "即将离开");
//访问完后,释放
semaphore.release();
} catch (Exception e) {
e.printStackTrace();
}
}
};
executor.execute(runnable);
}
// 退出线程池
executor.shutdown();
}
}
(1.4.5)Java 线程池
- 为什么用线程池
- 创建/销毁线程伴随着系统开销,过于频繁的创建/销毁线程,会很大程度上影响处理效率
例如: 记创建线程消耗时间T1,执行任务消耗时间T2,销毁线程消耗时间T3 如果T1+T3>T2,那么是不是说开启一个线程来执行这个任务太不划算了! 正好,线程池缓存线程,可用已有的闲置线程来执行新任务,避免了T1+T3带来的系统开销
- 线程并发数量过多,抢占系统资源从而导致阻塞
我们知道线程能共享系统资源,如果同时执行的线程过多,就有可能导致系统资源不足而产生阻塞的情况。运用线程池能有效的控制线程最大并发数,避免以上的问题
- 对线程进行一些简单的管理
比如:延时执行、定时循环执行的策略等 运用线程池都能进行很好的实现
- 线程池简介 在Java中,线程池的概念是Executor这个接口,具体实现为ThreadPoolExecutor类。
Executor接口是Executor框架的一个最基本的接口,Executor框架的大部分类都直接或间接地实现了此接口。 只有一个方法 void execute(Runnable command): 在未来某个时间执行给定的命令。该命令可能在新的线程、已入池的线程或者正调用的线程中执行,这由 Executor 实现决定。
- 线程池使用策略 (3.1)构造
- int corePoolSize,线程池中核心线程数最大值 线程池新建线程的时候,如果当前线程总数小于corePoolSize,则新建的是核心线程,如果超过corePoolSize,则新建的是非核心线程 核心线程默认情况下会一直存活在线程池中,即使这个核心线程啥也不干(闲置状态)。 如果指定ThreadPoolExecutor的allowCoreThreadTimeOut这个属性为true,那么核心线程如果不干活(闲置状态)的话,超过一定时间(时长下面参数决定),就会被销毁掉
- int maximumPoolSize,线程池中线程总数最大值 线程总数 = 核心线程数 + 非核心线程数。
- long keepAliveTime,线程池中非核心线程闲置超时时长 一个非核心线程,如果不干活(闲置状态)的时长超过这个参数所设定的时长,就会被销毁掉 如果设置allowCoreThreadTimeOut = true,则会作用于核心线程
- TimeUnit unit,枚举类型,keepAliveTime的单位
- BlockingQueue workQueue,线程池中任务队列:维护着等待执行的Runnable对象 当所有的核心线程都在干活时,新添加的任务会被添加到这个队列中等待处理,如果队列满了,则新建非核心线程执行任务。 常用的workQueue类型: SynchronousQueue 这个队列接收到任务的时候,会直接提交给线程处理,而不保留它,如果所有线程都在工作怎么办?那就新建一个线程来处理这个任务!所以为了保证不出现<线程数达到了maximumPoolSize而不能新建线程>的错误,使用这个类型队列的时候,maximumPoolSize一般指定成Integer.MAX_VALUE,即无限大 LinkedBlockingQueue 这个队列接收到任务的时候,如果当前线程数小于核心线程数,则新建线程(核心线程)处理任务;如果当前线程数等于核心线程数,则进入队列等待。由于这个队列没有最大值限制,即所有超过核心线程数的任务都将被添加到队列中,这也就导致了maximumPoolSize的设定失效,因为总线程数永远不会超过corePoolSize ArrayBlockingQueue 可以限定队列的长度,接收到任务的时候,如果没有达到corePoolSize的值,则新建线程(核心线程)执行任务,如果达到了,则入队等候,如果队列已满,则新建线程(非核心线程)执行任务,又如果总线程数到了maximumPoolSize,并且队列也满了,则发生错误 DelayQueue 队列内元素必须实现Delayed接口,这就意味着你传进去的任务必须先实现Delayed接口。这个队列接收到任务时,首先先入队,只有达到了指定的延时时间,才会执行任务
- ThreadFactory threadFactory,创建线程的方式,这是一个接口,你new他的时候需要实现他的Thread newThread(Runnable r)方法,一般用不上
- RejectedExecutionHandler handler,用于抛出异常
新建一个线程池时,一般只用5个参数的构造函数。 (3.2)添加任务 通过ThreadPoolExecutor.execute(Runnable command)方法即可向线程池内添加一个任务 (3.3)执行策略
- 线程数量未达到corePoolSize,则新建一个线程(核心线程)执行任务
- 线程数量达到了corePools,则将任务移入队列等待
- 队列已满,新建线程(非核心线程)执行任务
- 队列已满,总线程数又达到了maximumPoolSize,就会由上面handler(RejectedExecutionHandler)抛出异常
- 线程池类型 Java通过Executors提供了四种线程池,这四种线程池都是直接或间接配置ThreadPoolExecutor的参数实现的。
(1)CachedThreadPool() 可缓存线程池:
- 线程数无限制
- 有空闲线程则复用空闲线程,若无空闲线程则新建线程
- 一定程序减少频繁创建/销毁线程,减少系统开销
创建方法:
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
源码:
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
(2)FixedThreadPool() 定长线程池:
- 可控制线程最大并发数(同时执行的线程数)
- 超出的线程会在队列中等待
创建方法:
//nThreads => 最大线程数即maximumPoolSize
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(int nThreads);
//threadFactory => 创建线程的方法,这就是我叫你别理他的那个星期六!你还看!
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(int nThreads, ThreadFactory threadFactory);
源码:
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
(3)ScheduledThreadPool() 定长线程池:
- 支持定时及周期性任务执行。
创建方法:
//nThreads => 最大线程数即maximumPoolSize
ExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(int corePoolSize);
源码:
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
return new ScheduledThreadPoolExecutor(corePoolSize);
}
//ScheduledThreadPoolExecutor():
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE,
DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
new DelayedWorkQueue());
}
(4)SingleThreadExecutor() 单线程化的线程池:
- 有且仅有一个工作线程执行任务
- 所有任务按照指定顺序执行,即遵循队列的入队出队规则
创建方法:
ExecutorService singleThreadPool = Executors.newSingleThreadPool();
源码:
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
- Java 线程池的停止 Executor框架提供了Java线程池的能力,ExecutorService扩展了Executor,提供了管理线程生命周期的关键能力。其中,ExecutorService.submit返回了Future对象来描述一个线程任务,它有一个cancel()方法。 下面的例子扩展了上面的InterruptedExample,要求线程在限定时间内得到结果,否则触发超时停止。
public class InterruptByFuture {
public static void main(String[] args) throws Exception {
ExecutorService es = Executors.newSingleThreadExecutor();
Future<?> task = es.submit(new MyThread());
try {
//限定时间获取结果
task.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
//超时触发线程中止
System.out.println("thread over time");
} catch (ExecutionException e) {
throw e;
} finally {
boolean mayInterruptIfRunning = true;
task.cancel(mayInterruptIfRunning);
}
}
private static class MyThread extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println("count");
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("interrupt");
Thread.currentThread().interrupt();
}
}
System.out.println("thread stop");
}
public void cancel() {
interrupt();
}
}
}
Future的get方法可以传入时间,如果限定时间内没有得到结果,将会抛出TimeoutException。此时,可以调用Future的cancel()方法,对任务所在线程发出中断请求。 cancel()有个参数mayInterruptIfRunning,表示任务是否能够接收到中断。 mayInterruptIfRunning=true时,任务如果在某个线程中运行,那么这个线程能够被中断; mayInterruptIfRunning=false时,任务如果还未启动,就不要运行它,应用于不处理中断的任务 要注意,mayInterruptIfRunning=true表示线程能接收中断,但线程是否实现了中断不得而知。线程要正确响应中断,才能真正被cancel。 线程池的shutdownNow()会尝试停止池内所有在执行的线程,原理也是发出中断请求。
1.5)Java 并发模型
(1)并发模型 (1.1)并发与并行 并发程序是指在运行中有两个及以上的任务同时在处理,与之相关的概念并行,是指在运行中有两个及以上的任务同时执行,差别是在于处理和执行。在单核CUP中两个及以上任务的处理方式是让它们交替的进入CUP执行,这种对执行的处理方式就是并发。 并行只能发生在多核CUP中,每个CUP核心拿到一个任务同时执行,并行是并发的一个子集 与串行程序相比并发编程的优点: (1)提高硬件资源的利用率(特别是IO资源),提高系统的响应速度、减少客户端等待、增加系统吞吐量 (2)解决特定领域的问题,比如GUI系统,WEB服务器等。 (1.2)线程并发实现 并发实现包括三种:进程并发、线程并发与协程并发。 其中线程并发是Java的并发模型方式。 在操作系统中线程是包含在进程中的消费资源较少、运行迅速的最小执行单元,根据操作系统内核是否对线程可感知,把线程分为内核线程和用户线程。
- 基于内核线程 使用内核线程的一种高级接口--轻量级进程(Light Weight Process,LWP)实现的线程(通常意义上的线程),它与内核线程是一对一的关系。线程的创建,初始化,同步,切换(用户态、内核态)都需要内核调度器(Scheduler)进行调度,消耗内核资源,每一个轻量级进程都需要一个内核线程对应,所以这线程能创建的数量是也是有限的。
- 基于用户线程 建立在用户空间的上的线程,内核对此无感知。线程的创建、调度在用户态完成,不需要系统内核支援。由于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,而且由于操作系统只把处理器资源分配到进程,如“阻塞如何处理”,“多处理器系统中如何将线程映射到其它处理器上”这类问题解决起来将会异常困难,甚至不可能完成。
- 基于用户线程和内核线程混合 即使用内核线程(轻量级进程),也使用用户线程。用户线程依然建立在用户空间上,线程的创建、调度、处理器映射能够得到内核线程的支援,实现简单。用户线程与轻量级进程(内核线程)是N:M的对应关系,可以支持大规模的并发。
(1.3)线程并发通信 线程间通过协作才能合力完成一个任务,协作的基础就是通信。常用的线程间通信的方式有两种。
- 共享内存
设置一个共享变量,多个线程都可以对共享变量进行操作,共享变量就行通信的中介。共享内存通信方式实现简单,数据的共享还使得线程间的通信不需要数据的传送,直接对内存进行访问,加快了程序的执行效率。
但是多个线程操作同一个共享变量,势必会造成“数据争用”。竞争条件下必须让共享变量进入临界区进行保护,否则会产生数据不一致。 - 消息传递
(2)Java并发模型——线程模型
(2.1)简介
每一个JAVA线程都对应者一个内核线程,所以线程的创建、调度、上下文切换都需要系统内核的支持,会消耗系统资源。
JAVA线程间通信是通过共享内存实现的,锁在线程并发中有着举足轻重地位,使用JAVA多线程时需要非常小心的处理同步问题。
(2.2)问题 Java并发编程需要面对两个问题:
- 资源消耗问题: 包括线程的创建、上下文切换对资源的消耗,锁的互斥操作对资源的消耗,常用的解决方法有池化资源,根据计算类型保有适量线程,锁优化策略等。
- 线程安全问题: 线程安全问题,要想让并发程序正确的执行,需要解决原子性,可见性、有序性的问题,常用的保障线程安全的方法有加锁、不共享状态、不可变对象。 “线程与锁”模型是JAVA语言的并发模型。这也是大多数语言都支持的模型,由于其基本接近硬件本身运行的模式,可以解决的问题领域很多有着很高的运行效率,一直都是并发编程的首选。缺点是使用这样模型需要开发者时刻警惕线程安全问题,处理复杂的线程协作问题,关注计算资源的开销问题。
(2.3)并发机制
- 锁机制 比如synchronized或者ReentrantLock
- CAS算法 读的时候记录结果,写的时候检查是不是还是刚才读到的,如果是,那么说明读和写之间没有其他线程修改它的值,这段代码是原子执行的,可以进行修改操作;如果不是,那么说明其他线程修改了它的值,这段代码并没有原子执行,此时需要使用循环,重新读取,再检查,直至保证原子执行。如Volatile。 这种方式和锁有一些类似,都可以保证代码的原子执行,但是使用锁会涉及到一些线程的挂起和上下文切换问题,需要消耗资源,但是CAS仅是轮询,不涉及JVM级别。书中提到低度和中度竞争的情况下,CAS的代价是低于锁的,在高度竞争的情况下,CAS的代价是高于锁的(毕竟轮询也需要消耗资源,占用CPU),但高度竞争这种情况是比较少的。在一些细粒度的并发操作上,推荐还是使用CAS。
(2.4)并发工具
- 基础类:Synchronized、Volatile、Final
- java.util.concurrent包:原子类(atomic)、显示锁(ReentrantLock)、同步模式(CountDownLatch)、线程安全容器(ConcurrentHashMap、CopyOnWriteArrayList、Queue、TransferQueue) 感谢Doug Lea在Java 5中提供了他里程碑式的杰作java.util.concurrent包,它的出现让Java的并发编程有了更多的选择和更好的工作方式。Doug Lea的杰作主要包括以下内容:
- 更好的线程安全的容器
- 线程池和相关的工具类
- 可选的非阻塞解决方案
- 显示的锁和信号量机制
1.6)Java 线程安全
(1)什么是Java线程安全 多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。 线程安全有以下几种实现方式 (2)如何保证Java线程安全
- 不可变 不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。 不可变的类型:
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
- 互斥同步
- synchronized
- ReentrantLock。
- 非阻塞同步 互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
- CAS 随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。 乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
- AtomicInteger
- ABA
- 无同步方案
- 栈自闭 多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
- 线程本地存储(ThreadLocal)
- 可重入代码(Reentrant Code) 这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
1.7)Java 断点续传
(1)原理 在下载行为出现中断的时候,记录下中断的位置信息,然后在下次行为开始的时候,直接从记录的这个位置开始下载内容,而不再从头开始。 分为两步:
- 当“上传(下载)的行为”出现中断,我们需要记录本次上传(下载)的位置(position)。
- 当“续”这一行为开始,我们直接跳转到postion处继续上传(下载)的行为。
(2)代码
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.RandomAccessFile;
public class Test {
// step1:首先,我们定义了一个变量position,记录在发生中断的时候,已完成读写的位置。(这是为了方便,实际来说肯定应该讲这个值存到文件或者数据库等进行持久化)
private static int position = -1;
public static void main(String[] args) {
// 源文件与目标文件
File sourceFile = new File("D:/", "test.txt");
File targetFile = new File("E:/", "test.txt");
// 输入输出流
FileInputStream fis = null;
FileOutputStream fos = null;
// 数据缓冲区
byte[] buf = new byte[1];
try {
fis = new FileInputStream(sourceFile);
fos = new FileOutputStream(targetFile);
// 数据读写
while (fis.read(buf) != -1) {
fos.write(buf);
// step2:然后在文件读写的while循环中,我们去模拟一个中断行为的发生。这里是当targetFile的文件长度为3个字节则模拟抛出一个我们自定义的异常。(我们可以想象为实际下载中,已经上传(下载)了”x”个字节的内容,这个时候网络中断了,那么我们就在网络中断抛出的异常中将”x”记录下来)。
if (targetFile.length() == 3) {
position = 3;
throw new FileAccessException();
}
}
} catch (FileAccessException e) {
//step3:开启”续传“行为,即keepGoing方法.
keepGoing(sourceFile,targetFile, position);
} catch (FileNotFoundException e) {
System.out.println("指定文件不存在");
} catch (IOException e) {
// TODO: handle exception
} finally {
try {
// 关闭输入输出流
if (fis != null)
fis.close();
if (fos != null)
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private static void keepGoing(File source,File target, int position) {
// step3.1:我们起头让线程休眠10秒钟,这正是为了让我们运行程序看到效果。
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
// step3.2:在“续传”行为开始后,通过RandomAccessFile类来包装我们的文件,然后通过seek将指针指定到之前发生中断的位置进行读写就搞定了。
(实际的文件下载上传,我们当然需要将保存的中断值上传给服务器,这个方式通常为
try {
RandomAccessFile readFile = new RandomAccessFile(source, "rw");
RandomAccessFile writeFile = new RandomAccessFile(target, "rw");
readFile.seek(position);
writeFile.seek(position);
// 数据缓冲区
byte[] buf = new byte[1];
// 数据读写
while (readFile.read(buf) != -1) {
writeFile.write(buf);
}
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
class FileAccessException extends Exception {
}
(3)实现结果
运行程序,那么文件就会开启“由D盘上传到E盘的过程”,我们首先点开E盘,会发现的确多了一个test.txt文件,打开它发现内容如下:
这个时候我们发现内容只有“abc”。这是在我们预料以内的,因为我们的程序模拟在文件上传了3个字节的时候发生了中断。
等待10秒钟过去,然后再点开该文件,发现内容的确已经变成了“abc”,由此也就完成了续传。