2.1. 线程池打爆机器
首先,我们聊一个非常非常低级,但又产生了严重后果的多线程错误。
通常,我们创建线程的方式有Thread,Runnable和线程池三种。随着Java1.8的普及,现在最常用的就是线程池方式。
有一次,我们线上的服务器出现了僵死,就连远程ssh,都登录不上,只能无奈的重启。大家发现,只要启动某个应用,过不了几分钟,就会出现这种情况。最终定位到了几行让人啼笑皆非的代码。
有位对多线程不太熟悉的同学,使用了线程池去异步处理消息。通常,我们都会把线程池作为类的静态变量,或者是成员变量。但是这位同学,却将它放在了方法内部。也就是说,每当有一个请求到来的时候,都会创建一个新的线程池。当请求量一增加,系统资源就被耗尽,最终造成整个机器的僵死。
void realJob(){
ThreadPoolExecutor exe = new ThreadPoolExecutor(...);
exe.submit(new Runnable(){...})
}
这种问题如何去避免?只能通过代码review。所以多线程相关的代码,哪怕是非常简单的同步关键字,都要交给有经验的人去写。即使没有这种条件,也要非常仔细的对这些代码进行review。
2.2. 锁要关闭
相比较synchronized关键字加的独占锁,concurrent包里面的Lock提供了更多的灵活性。可以根据需要,选择公平锁与非公平锁、读锁与写锁。
但Lock用完之后是要关闭的,也就是lock和unlock要成对出现,否则就容易出现锁泄露,造成了其他的线程永远了拿不到这个锁。
如下面的代码,我们在调用lock之后,发生了异常,try中的执行逻辑将被中断,unlock将永远没有机会执行。在这种情况下,线程获取的锁资源,将永远无法释放。
private final Lock lock = new ReentrantLock();
void doJob(){
try{
lock.lock();
//发生了异常
lock.unlock();
}catch(Exception e){
}
}
正确的做法,就是将unlock函数,放到finally块中,确保它总是能够执行。
由于lock也是一个普通的对象,是可以作为函数的参数的。如果你把lock在函数之间传来传去的,同样会有时序逻辑混乱的情况。在平时的编码中,也要避免这种把lock当参数的情况。
2.3. wait要包两层
Object作为Java的基类,提供了四个方法wait
wait(timeout)
notify
notifyAll
,用来处理线程同步问题,可以看出wait等函数的地位是多么的高大。在平常的工作中,写业务代码的同学使用这些函数的机率是比较小的,所以一旦用到很容易出问题。
但使用这些函数有一个非常大的前提,那就是必须使用synchronized进行包裹,否则会抛出IllegalMonitorStateException。比如下面的代码,在执行的时候就会报错。
final Object condition = new Object();
public void func(){
condition.wait();
}
类似的方法,还有concurrent包里的Condition对象,使用的时候也必须出现在lock和unlock函数之间。
为什么在wait之前,需要先同步这个对象呢?因为JVM要求,在执行wait之时,线程需要持有这个对象的monitor,显然同步关键字能够完成这个功能。
但是,仅仅这么做,还是不够的,wait函数通常要放在while循环里才行,JDK在代码里做了明确的注释。
重点:这是因为,wait的意思,是在notify的时候,能够向下执行逻辑。但在notify的时候,这个wait的条件可能已经是不成立的了,因为在等待的这段时间里条件条件可能发生了变化,需要再进行一次判断,所以写在while循环里是一种简单的写法。
final Object condition = new Object();
public void func(){
synchronized(condition){
while(<条件成立>){
condition.wait();
}
}
}
带if条件的wait和notify要包两层,一层synchronized,一层while,这就是wait等函数的正确用法。
2.4. 不要覆盖锁对象
使用synchronized关键字时,如果是加在普通方法上的,那么锁的就是this对象;如果是加载static方法上的,那锁的就是class。除了用在方法上,synchronized还可以直接指定要锁定的对象,锁代码块,达到细粒度的锁控制。
如果这个锁的对象,被覆盖了会怎么样?比如下面这个。
List listeners = new ArrayList();
void add(Listener listener, boolean upsert){
synchronized(listeners){
List results = new ArrayList();
for(Listener ler:listeners){
...
}
listeners = results;
}
}
上面的代码,由于在逻辑中,强行给锁listeners
对象进行了重新赋值,会造成锁的错乱或者失效。
为了保险起见,我们通常把锁对象声明成final类型的。
final List listeners = new ArrayList();
或者直接声明专用的锁对象,定义成普通的Object对象即可。
final Object listenersLock = new Object();
2.5. 处理循环中的异常
在异步线程里处理一些定时任务,或者执行时间非常长的批量处理,是经常遇到的需求。我就不止一次看到小伙伴们的程序执行了一部分就停止的情况。
排查到这些中止的根本原因,就是其中的某行数据发生了问题,造成了整个线程的死亡。
我们还是来看一下代码的模板。
volatile boolean run = true;
void loop(){
while(run){
for(Task task: taskList){
//do . sth
int a = 1/0;
}
}
}
在loop函数中,执行我们真正的业务逻辑。当执行到某个task的时候,发生了异常。这个时候,线程并不会继续运行下去,而是会抛出异常直接中止。在写普通函数的时候,我们都知道程序的这种行为,但一旦到了多线程,很多同学都会忘了这一环。
值得注意的是,即使是非捕获类型的NullPointerException
,也会引起线程的中止。所以,时刻把要执行的逻辑,放在try catch中,是个非常好的习惯。
volatile boolean run = true;
void loop(){
while(run){
for(Task task: taskList){
try{
//do . sth
int a = 1/0;
}catch(Exception ex){
//log
}
}
}
}
2.6. HashMap正确用法
HashMap在多线程环境下,会产生死循环问题。这个问题已经得到了广泛的普及,因为它会产生非常严重的后果:CPU跑满,代码无法执行,jstack查看时阻塞在get方法上。
至于怎么提高HashMap效率,什么时候转红黑树转列表,这是阳春白雪的八股界话题,我们下里巴人只关注怎么不出问题。
网络上有详细的文章描述死循环问题产生的场景,大体因为HashMap在进行rehash时,会形成环形链。某些get请求会走到这个环上。JDK并不认为这是个bug,虽然它的影响比较恶劣。
如果你判断你的集合类会被多线程使用,那就可以使用线程安全的ConcurrentHashMap来替代它。
HashMap还有一个安全删除的问题,和多线程关系不大,但它抛出的是ConcurrentModificationException,看起来像是多线程的问题。我们一块来看看它。
Map<String, String> map = new HashMap<>();
map.put("xjjdog0", "狗1");
map.put("xjjdog1", "狗2");
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if ("xjjdog0".equals(key)) {
map.remove(key);
}
}
上面的代码会抛出异常,这是由于HashMap的Fail-Fast机制。如果我们想要安全的删除某些元素,应该使用迭代器。
Iterator<Map.Entry<String, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, String> entry = iterator.next();
String key = entry.getKey();
if ("xjjdog0".equals(key)) {
iterator.remove();
}
}
2.7. 线程安全的保护范围
使用了线程安全的类,写出来的代码就一定是线程安全的么?答案是否定的。
线程安全的类,只负责它内部的方法是线程安全的。如我我们在外面把它包了一层,那么它是否能达到线程安全的效果,就需要重新探讨。
比如下面这种情况,我们使用了线程安全的ConcurrentHashMap来存储计数。虽然ConcurrentHashMap本身是线程安全的,不会再出现死循环的问题。但addCounter函数,明显是不正确的,它需要使用synchronized函数包裹才行。
private final ConcurrentHashMap<String,Integer> counter;
public int addCounter(String name) {
Integer current = counter.get(name);
int newValue = ++current;
counter.put(name,newValue);
return newValue;
}
这是开发人员常踩的坑之一。要达到线程安全,需要看一下线程安全的作用范围。如果更大维度的逻辑存在同步问题,那么即使使用了线程安全的集合,也达不到想要的效果。
2.8. volatile作用有限
volatile关键字,解决了变量的可见性问题,可以让你的修改,立马让其他线程给读到。
虽然这个东西在面试的时候问的挺多的,包括ConcurrentHashMap中对volatile的那些优化。但在平常的使用中,你真的可能只会接触到boolean变量的值修改。
volatile boolean closed;
public void shutdown() {
closed = true;
}
千万不要把它用在计数或者线程同步上,比如下面这样。
volatile count = 0;
void add(){
++count;
}
这段代码在多线程环境下,是不准确的。这是因为volatile只保证可见性,不保证原子性,多线程操作并不能保证其正确性。
直接用Atomic类或者同步关键字多好,你真的在乎这纳秒级别的差异么?
2.9. 日期处理要小心
很多时候,日期处理也会出问题。这是因为使用了全局的Calendar,SimpleDateFormat等。当多个线程同时执行format函数的时候,就会出现数据错乱。
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
Date getDate(String str){
return format(str);
}
为了改进,我们通常将SimpleDateFormat放在ThreadLocal中,每个线程一份拷贝,这样可以避免一些问题。当然,现在我们可以使用线程安全的DateTimeFormatter了。
static DateTimeFormatter FOMATTER = DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm:ss");
public static void main(String[] args) {
ZonedDateTime zdt = ZonedDateTime.now();
System.out.println(FOMATTER.format(zdt));
}
2.10. 不要在构造函数中启动线程
在构造函数,或者static代码块中启动新的线程,并没有什么错误。但是,强烈不推荐你这么做。
因为Java是有继承的,如果你在构造函数中做了这种事,那么子类的行为将变得非常魔幻。另外,this对象可能在构造完毕之前,出递到另外一个地方被使用,造成一些不可预料的行为。
所以把线程的启动,放在一个普通方法,比如start中,是更好的选择。它可以减少bug发生的机率。
End
wait和notify是非常容易出问题的地方,
编码格式要求非常严格。synchronized关键字相对来说比较简单,但同步代码块的时候依然有许多要注意的点。这些经验,在concurrent包所提供的各种API中依然实用。我们还要处理多线程逻辑中遇到的各种异常问题,避免中断,避免死锁。规避了这些坑,基本上多线程代码写起来就算是入门了。
许多java开发,都是刚刚接触多线程开发,在平常的工作中应用也不是很多。如果你做的是crud的业务系统,那么写一些多线程代码的时候就更少了。但总有例外,你的程序变得很慢,或者排查某个问题,你会直接参与到多线程的编码中来。
我们的各种工具软件,也在大量使用多线程。从Tomcat,到各种中间件,再到各种数据库连接池缓存等,每个地方都充斥着多线程的代码。
即使是有经验的开发,也会陷入很多多线程
的陷阱。因为异步会造成时序的混乱,必须要通过强制的手段达到数据的同步。多线程运行,首先要保证准确性,使用线程安全的集合进行数据存储;还要保证效率,毕竟使用多线程的目标就是如此。
希望本文中的这些实际案例,让你对多线程的理解,更上一层楼。