1.资源共享带来的问题
临界区Critical Section 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。 在多个线程对共享资源读写操作时发生指令交错,就会出现问题。
2.synchronized
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
synchronized俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其它线程再想获取这个对象锁时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。 Synchronized(对象)中的对象可以理解为一个房间,有唯一的入口,房间一次只能进入一个人,线程可以理解为人。当线程执行到Synchronized(对象)时,可以理解为一个人进入到房间,并锁住了门拿走了钥匙,并在房间内执行临界区的代码。 当发生了上下文切换时,第二个人也就是另外一个线程执行到Synchronized(对象),发现了门被锁住了,只能在门外面等着,线程阻塞住了。 即使第一个线程的CPU时间用完了,被踢出了门外(锁住了对象就并不能一直执行下去),这时门还是锁住的,第一个线程仍拿着钥匙,第二个线程还在阻塞状态进不来,只有下次轮到第一个线程自己再次获得时间片时才能开门进入 当第一个线程执行完Synchronized(对象)临界区的代码,这时候才会从房间出来并解开门上的锁,唤醒第二个线程把钥匙给他。第二个线程这时才可以进入房间,锁住了门拿上钥匙,执行它Synchronized(对象)临界区的代码。
3.方法上的synchronized
4.变量的线程安全分析
成员变量和静态变量的线程安全
如果它们没有共享,则线程安全。 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况:如果只有读操作,则线程安全。如果有读写操作,则这段代码是临界区,需要考虑线程安全。 局部变量的线程安全
局部变量是线程安全的。因为局部变量存在于局部变量表中,而局部变量表存在于栈帧中,JVM栈是线程私有的。 局部变量引用的对象则未必是线程安全的。如果该对象没有逃离方法的作用访问,它是线程安全的。如果该对象逃离方法的作用范围,需要考虑线程安全。 方法的访问修饰符可以保护线程安全。比如private和final。当一个子类new了一个thread,在父类的方法中加一个private,可以防止子类对父类产生干扰。 常见的线程安全类
String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent包下的类 这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为:它们的每个方法是原子的,但注意它们多个方法的组合不是原子的。 String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的。
5.Monitor概念
其中,Klass Word是一个指针指向了对象从属的类。其中Mark Word结构为: Monitor 被翻译为监视器或管程,是由操作系统提供的。每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向Monitor对象的指针。 刚开始Monitor中Owner为null; 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner,obj会与monitor进行关联,obj对象头的Mark Word 中就被设置指向Monitor对象的指针; 在Thread-2上锁的过程中,如果Thread-3,Thread-4,Thread-5也来执行synchronized(obj),就会进入EntryList BLOCKED; Thread-2 执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争的时是非公平的; 图中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程,后面讲wait-notify时会分析。
6.synchronized原理进阶
1. 轻量级锁(没有竞争) 如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。轻量级锁对使用者是透明的,即语法仍然是synchronized。 假设有两个方法同步块,利用同一个对象进行加锁。 其过程如下: (1)创建锁记录(Lock Record)对象,每个线程的栈帧都包含一个锁记录的对象。 (2)让锁记录中Object reference指向锁对象,并尝试用CAS替换Object的Mark Word,将Mark Word的值存入锁记录。其中“lock record 地址00”为00故为轻量级锁 (3)如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁。并且为轻量级锁。 (4)如果CAS失败,有两种情况:如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程。如果是自己执行了 synchronized 锁重入,那么再添加一条Lock Record作为重入的计数。 (5)当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一。 (6)当退出synchronized代码块(解锁时)锁记录的值不为 null,这时使用CAS将 Mark Word 的值恢复给对象头。成功,则解锁成功。失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。 2. 膨胀锁(有竞争) 如果在尝试加轻量级锁的过程中,CAS操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。其过程如下所示: (1)当Thread-1进行轻量级加锁时,Thread-0已经对该对象加了轻量级锁。 (2)这时Thread-1加轻量级锁失败,进入锁膨胀流程即为Object对象申请Monitor锁,让Object指向重量级锁地址。然后自己进入Monitor的EntryList BLOCKED。轻量级锁没有阻塞状态。注意object中的“Monitor 地址 10”。 当 Thread-0 退出同步块解锁时,使用CAS将Mark Word的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程。 3. 自旋优化 重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。 自旋会占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。 在Java 6之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋。 4. 偏向锁(没有竞争) 轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。 Java 6 中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的 Mark Word 头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。 偏向状态 一个对象创建时:如果开启了偏向锁(默认开启),那么对象创建后,markword值为0x05即最后3位为101,这时它的thread、epoch、age都为0。偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加VM参数来禁用延迟。 用锁的顺序:优先使用偏向锁,然后轻量锁,最后重量锁。 撤销偏向锁 (1)Hashcode()方法会禁用偏向锁,因为偏向锁的对象MarkWord中存储的是线程id,没有足够的位置存储hashcode。 (2)当有其它线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。 (3)调用wait/notify。 批量重偏向 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的 Thread ID。 当撤销偏向锁阈值超过20次后,JVM会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程。 批量撤销 当撤销偏向锁阈值超过40次后,JVM会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。 锁消除 JIT即时编译器会对字节码文件优化,当锁对象是局部变量时
7.wait notify
Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态 BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片 BLOCKED线程会在Owner线程释放锁时唤醒 WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入EntryList 重新竞争。 Wait(long timeout):有时限的等待,时间到后会自动唤醒,可以被其他线程提前唤醒。
8.Park & Unpark
它们是LockSupport类中的方法。 Park & Unpark与wait & notify相比:
- wait,notify和notifyAll必须配合Object Monitor一起使用,而park,unpark不必。
- park & unpark 是以线程为单位来阻塞和唤醒线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么精确
- park & unpark可以先unpark,而wait & notify不能先notify
9.活跃性
- 死锁
一个线程需要同时获取多把锁,这时就容易发生死锁。t1线程获得对象锁,接下来想获取B对象的锁t2线程获得B对象锁,接下来想获取A对象的锁。这样就会发生死锁的现象。 检测死锁可以使用jconsole工具,或者使用jps定位进程id,再用jstack定位死锁。 - 活锁
活锁出现在两个线程互相改变对方的结束条件,最后谁也无法结束。 - 饥饿
一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束。顺序加锁能够解决死锁现象,但是也会发生饥饿问题,可以用ReentrantLock解决。
10.ReentrantLock
ReentrantLock相对于synchronized它具备如下特点:
- 可中断
- 可以设置超时时间
- 可以设置为公平锁
- 支持多个条件变量
- 与synchronized一样,都支持可重入
基本语法: 可重入 可重入是指同一个线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁。如果是不可重入锁,那么第二次获得锁时,自己也会被锁挡住。 可打断 线程t1在等待获取锁的过程中,其他线程可以采用interrupt()方法打断t1线程的等待,lock.lock()方法是不可打断的,lock. lockInterruptibly ()方法是可以被打断。可打断是为了避免一直在等待锁的获取,避免死锁的发生。 锁超时 等待一段时间后无法获取锁,就放弃等待。tryLock()方法,返回是否获取锁。tryLock(time)表示等一段时间后返回是否获取锁。 公平锁 ReentrantLock和synchronized中的monitor默认是不公平的(不按照阻塞队列的顺序来分配释放的锁)。本意是为了解决饥饿问题。 构造时传入true会变成公平锁,公平锁会降低并发度。 条件变量 synchronized中也有条件变量,就是monitor里面的waitSet,当条件不满足时进入waitSet 等待。ReentrantLock的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的。 - Synchronized中,那些不满足条件的线程都在一间休息室等消息
- ReentrantLock支持多间休息室,有专门等烟的休息室、专门等早餐的休息室、唤醒时也是按休息室来唤醒
创建条件变量: Condition cond1 = lock.newCondition()。await前需要提前获取锁。Cond1.await()进入等待。Cond1.signal()唤醒cond1中的某一个线程。Cond1.signalAll唤醒cond1中所有线程。 使用要点: - await执行后,会释放锁,进入 conditionObject 等待
- await的线程被唤醒(或打断、或超时)取重新竞争 lock 锁
- 竞争lock锁成功后,从await后继续执行
|