JavaSE中线程与并行API框架学习笔记——线程为什么会不安全?

前言:休整一个多月之后,终于开始投简历了。这段时间休息了一阵子,又病了几天,真正用来复习准备的时间其实并不多。说实话,心里不是非常有底气。

这可能是学生时代遗留的思维惯性——总想着做好万全准备才去做事。当然,在学校里考试之前当然要把所有内容学一遍和复习一遍。但是,到了社会里做事,很多时候都是边做边学。应聘如此,工作如此,很多的挑战都是如此。没办法,硬着头皮上吧。

3.5 线程的分组管理

在实际的开发过程当中,可能会有多个线程同时存在,这对批量处理有了需求。这就有点像用迅雷下载电视剧,假设你在同时下载《越狱》和《纸牌屋》,这时候女朋友说想先看《越狱》,那么为了尽快满足她就要先暂停其他电视剧的下载。一个一个点暂停效率很低,最好的方法是批量选择所有的目标任务再点暂停。

每个线程都属于某个线程群组,即ThreadGroup。如果在main()主流程中产生了一个线程,该线程就属于main线程群组。我们可以使用这样的语句取得目前线程所属线程组名:

         Thread.currentThread().getThreadGroup().getName();

每个线程产生时,都会归入某个线程群组。如果没有指定,则会归入产生该子线程的线程群组。当然,也可以自行指定线程群组。需要特别注意的是,线程一旦归到某个群组,就无法更换。

java.lang.ThreadGroup类如其名,可以管理群组中的线程。可以使用以下方法产生群组,并在产生线程的时候指定所属群组:

 ThreadGroup threadGroup1 = new ThreadGroup("group1");
ThreadGroup threadGroup2 = new ThreadGroup("group2");
Thread thread1 = new Thread(threadGroup1, "group1's member");
Thread thread2 = new Thread(threadGroup2, "group2's member");

ThreadGroup的某些方法,可以对群组中所有线程产生作用。例如,interrupt()方法可以中断群组里面所有的线程,setMaxPriority()方法可以设定群组中所有线程最大优先权(本来就拥有更高优先权的线程不受影响)。

如果想要一次性地取得群组中所有线程,可以使用enumerate()方法:

 Thread[] threads = new Thread[threadGroup1.activeCount()];
threadGroup1.enumerate(threads);

在这个代码片段里面,activeCount()方法取得群组的线程数量,enumerate()方法要传入Thread数组,这会将线程对象设定到每个数组索引。

3.5.1 线程群组的异常处理

把若干线程归入到某个特定的线程群组之后,如果群组中某个线程发生了异常,有可能我们会采用统一的处理方式。

ThreadGroup中有个uncaughtException()方法,群组中某个线程发生异常而未捕捉时,JVM会调用此方法进行处理。如果ThreadGroup有父ThreadGroup,就会调用父ThreadGroup的uncaughtException()方法,否则看看异常是否为ThreadDeath实例。如果是那就什么都不做;如果不是就要调用异常的printStrackTrace()。如果必须定义ThreadGroup中线程的异常处理行为,可以重新定义此方法。例如:

 /**
* Created by Levenyes on 2017/7/29.
*/
public class ThreadGroupDemo {
public static void main(String[] args) {
ThreadGroup tg1 = new ThreadGroup("tg1") {
@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.printf("%s: %s%n", t.getName(), e.getMessage());
}
}; Thread t1 = new Thread(tg1, new Runnable() {
public void run() {
throw new RuntimeException("测试异常");
}
}
); t1.start();
}
}

uncaughtException()方法第一个参数可取得发生异常的线程实例,第二个参数可取得异常对象,实验用例中显示了线程的名称以及异常信息:

JavaSE中线程与并行API框架学习笔记——线程为什么会不安全?

3.6 为什么线程会不安全?

以前刚毕业那会儿背面试题就背到过,“String是定长的,StringBuffer和StringBuilder是不定长的;StringBuffer是线程安全的,StringBuilder是线程不安全的”。那么问题就来了,为什么线程会不安全呢?

我们在之前的文章里讲到过ArrayList类。之前在单线程的情况下使用是没有问题的,但如果在多线程的环境下使用会不会出现意外呢?

 import java.util.*;

 /**
* 线程不安全实验用例
*/
public class ArrayListDemo {
public static void main(String[] args) {
final ArrayList list = new ArrayList ();
Thread t1 = new Thread() {
public void run() {
while(true) {
list.add(1);
}
}
};
Thread t2 = new Thread() {
public void run() {
while(true) {
list.add(2);
}
}
};
t1.start();
t2.start();
}
}

如果你跟我一样让上面这段代码跑起来,就“有可能”出现下面这些异常:

JavaSE中线程与并行API框架学习笔记——线程为什么会不安全?

为什么要强调说是“有可能”呢?这是几率问题,有可能发生,也有可能没发生,就因数组长度过长,JVM分配到的内存不够,而发生java.lang.OutOfMemoryError,我们不讨论OutOfMemoryError问题,而将焦点放在为何会出现ArrayIndexOutOfBoundsException异常。

首先来看ArrayList在JavaSE源代码中的add()方法:

     /**
* Appends the specified element to the end of this list.
*
* @param e element to be appended to this list
* @return <tt>true</tt> (as specified by {@link Collection#add})
*/
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}

这个方法先会检查数组的大小是否已经到了最大值,如果是的话就会先做增加最大值的动作,再把新元素加入到数组当中来。按理来说,不可能会出现溢出的情况。

然而,如果有t1、t2两个线程同时调用add()方法,假设t1执行add()已经到了elementData[size++] = e这行,这个时候CPU调度器将t1置为Runnable状态,将t2置为Running状态,而t2执行add()已经完成elementData[size++] = e这行的执行,此时刚好数组满了。如果这个时候CPU调度器将t2置为Runnable状态,将t1置为Running状态,t1就会继续跑elementData[size++] = e这一行,因为数组已经满了,就会出现ArrayIndexOutOfBoundsException异常。

用术语来说,这就是线程存取同一对象相同资源时所引发的竞速,即Race condition。类似这样因为多线程而出错的情况,我们就可以理解成线程有了出错的危险,即不安全。

像ArrayList这样的类,我们习惯称为不具备线程安全(Thread-safe)或线程不安全的类。

3.7 保证同步的syncronized

如何解决线程不安全的问题呢?我们可以使用关键字synchronized,顾名思义,就是同步的意思。

 public synchronized boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}

想办法在add()方法前面加上synchronized关键字之后,再次运行前面那个demo,ArrayIndexOutOfBoundsException就不会再出现了。这是为什么呢?

这是因为每个对象都会有个内部锁定,即IntrinsicLock,或称为监控锁定,即Monitor lock。被标识为synchronized的区块将会被监控,任何线程要执行synchronized区块将会被监控,任何线程要执行该区块都必须先取得指定的对象锁定。

如果A线程已取得对象锁定开始执行synchronized区块,B线程也想执行synchronized区块,会因无法取得对象锁定而进入等待锁定状态,直到A线程释放锁定(例如执行完了区块内的任务),B线程才有可能取得锁定而执行synchronized区块。

举个不太恰当的例子,这就好像你跟一个好哥们到*自驾游,任意时刻都只能有一个人在驾驶位上开车。如果你在开车,你的哥们就只能在旁边看着。只有等你停车从驾驶位上走开,他才可以坐上去开车。如果你们是一个人负责踩刹车和油门,另一个人负责握方向盘,就很有可能发生交通意外。

值得一提的是,线程在等待对象锁定时,也会进入Blocked状态。所以我们可以进一步扩展在上一篇文章提到过的线程生命周期示意图:

JavaSE中线程与并行API框架学习笔记——线程为什么会不安全?

线程如果因为尝试执行synchronized区块而进入Blocked状态,在取得锁定之后,会先回到Runnable状态,等待CPU调度器排入Running状态。

synchronized不是只可以声明在方法上,也可以描述句方式使用。例如下面这样的写法:

     public void add(Object o) {
synchronized (this) {
if(next == list.length) {
list = Arrays.copyOf(list, list.length * 2);
}
list[next++] = o;
}
}

这个程序片段的意思就是,在线程要执行synchronized区块时,必须取得括号中指定的对象锁定。事实上此语法目的之一,可应用于不想锁定整个方法,而只想锁定会发生竞速状况的区块,在执行完区块后线程即释放锁定,其他线程就有机会再竞争对象锁定,相较于将整个方法声明为synchronized来说,会比较有效率。

我们在之前的文章介绍过的Collection和Map,它们的实现类大多没有考虑线程安全,其实可以使用自带的synchronizedCollection()、synchronizedList()、synchronizedSet()、synchronizedMap()等方法获取新增线程安全特性的对象。

3.8 线程安全小结

值得注意的是,synchronized声明固然可以让线程变得“安全”,不容易发生竞速状况,但这样的线程安全特性需要付出代价。首先是很大概率会使运行效率有程度不一的下降,因为会一旦发生因synchronized而起的阻塞状况就会令运行时间变长。其次,如果程序设计不当,还有可能会发生死锁这样严重的问题,即Dead Lock。

一个线程要完成一个事务可能需要多个资源,就好像你要做饭,需要用到菜刀和砧板。如果这时候你跟你的舍友都要切菜,你拿着菜刀不肯放,他拿着砧板不肯放,那就谁都吃不上饭。对应到多线程当中,一个事务需要同时利用a和b资源才可以完成,有可能出现A线程锁定a资源,B线程锁定b资源,两个线程同时在等待对方放弃锁定。

当然了,我们人是活的,可以互相商量着让谁先切菜。但是程序是“死”的,如果没有一个良好的设计机制避免死锁发生,很有可能就会出现多个线程同时等待且不可能等待结束的状况。

因此,如果你的程序没有出现竞速状况的可能性,就尽量不要用synchronized声明。一旦使用,就要考虑到性能下降和可能发生死锁这两个关键点,尽可能在获取线程安全这一特性的同时尽可能避免发生死锁和尽可能少地牺牲效率。

相关文章推荐:

JavaSE中Collection集合框架学习笔记(1)——具有索引的List

JavaSE中Collection集合框架学习笔记(2)——拒绝重复内容的Set和支持队列操作的Queue

JavaSE中Collection集合框架学习笔记(3)——遍历对象的Iterator和收集对象后的排序

JavaSE中Map框架学习笔记

JavaSE中线程与并行API框架学习笔记1——线程是什么?

如果你喜欢我的文章,可以扫描关注我的个人公众号“李文业的思考笔记”。

不定期地会推送我的原创思考文章。

JavaSE中线程与并行API框架学习笔记——线程为什么会不安全?

上一篇:3、二进制安装K8s之部署kube-apiserver


下一篇:使用CodeDOM动态编译一个字符串表达式