线程与锁

线程和锁,先从简单的示例1开始:

**public class App {
    public static void main(String[] args) throws InterruptedException {
        class Counter {
            private int count = 0;
            void increment() {
                ++count;
            }
            private int getCount() {
                return count;
            }
        }
        final Counter counter = new Counter();
        class CountingThread extends Thread {
            @Override
            public void run() {
                for (int x = 0; x < 10000; ++x) {
                    counter.increment();
                }
            }
        }
        CountingThread t1 = new CountingThread();
        CountingThread t2 = new CountingThread();
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(counter.getCount());
    }
}

这是一个最简单的示例,两个线程分别执行count++ 10000次,但最后getCount()时,结果并不一定是20000。这就是并发的最简单最常见的问题:对共享资源的访问存在竞争,即并发中的竞态条件

解决的常规做法就是加锁,如下:

synchronized void increment() {
    ++count;
}

对共享资源的操作加锁,使得同时只有一个线程操作。但还是不能从根上解决问题。看下面的示例:
示例二

public class App {
    static boolean answerReady = false;
    static int answer = 0;
    static Thread t1 = new Thread() {
        @Override
        public void run() {
            answer = 21;
            answerReady = true;
        }
    };
    static Thread t2 = new Thread() {
        @Override
        public void run() {
            if (answerReady) {
                System.out.println("The meaning of life is: " + answer);
            } else {
                System.out.println("I don't know the answer");
            }
        }
    };
    public static void main(String[] args) throws InterruptedException {
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

上述代码可能输出“The meaning of life is: 21”,可能是“I don’t know the answer”,这无需介绍,因为线程1和2执行顺序未定。但要命的是,可能输出“The meaning of life is: 0”。

难道answerReady = true;先执行?是的,编译器的静态优化、JVM的动态优化以及硬件都可能打乱代码的执行顺序。但就算是执行顺序一致,线程1的修改可能都线程2不可见。这就是线程的内存可见性问题。

解决线程同步操作和内存可见性问题后,线程锁带来的另一个问题就是死锁。死锁是一个线程想要多把锁引起的。

比如线程1需要拿到锁A,在锁中需要拿到锁B,而线程2刚好相反。这样一来,如果线程1拿到锁A的同时,线程2拿到锁B,两个线程都在等待对方释放锁,从而导致死锁。好比一个建筑的大门里有个小门,如果一个人拿到大门的钥匙,进去后发现小门的钥匙被另一个拿走了。

解决方式是,只能先拿到大门钥匙,才能拿小门的。这就是最简单的解决死锁的方法:保证锁顺序,然后按顺序获取锁(使用对象散列值作为全局锁的顺序)。但这种方式对于复杂逻辑时,处理起来太麻烦,而且容易出错,散列值也可能冲突。

java提供了很多平方工具,解决了锁中断,超时,读写锁,锁判断,原子变量等高级用法。在开发过程中,尽量使用它们。详细可以看java.util.concurrent包提供的类。

这儿说下条件变量的情况:

获取锁;
while (条件状态不满足) {
    释放锁;
    线程挂起等待,直到条件满足通知;
    重新获取锁;
}
临界区操作;
释放锁;

条件变量最主要的作用是用来管理线程执行对某些状态的依赖性。比如一个线程是某个队列的消费者,它必须要等到队列中有数据时才能执行,如果队列为空,则会一直等待挂起,直到另外一个线程在队列中存入数据,并通知先前挂起的线程,该线程才会唤醒重新开始执行。使用类似:

ReentrantLock lock = new ReentrantLock(); 
Condition condition = lock.newCondition();
lock.lock(); try {
while (!«条件为真») {condition.await();}
«使用共享资源»
} finally { lock.unlock(); }

虽然java提供的并发工具足够多,但根据场景合理使用才是关键。比如使用生产者-消费者模式解耦,使用阻塞队列,使用ConcurrentHashMap分段锁减少锁等待。甚至使用分段归并的方式而不是对共享资源加锁。如果每个任务执行时间不等,work-stealing模式是一个不错的选择。

既然说到线程,那就涉及线程创建,一般都会使用线程池而不是直接创建的方式。但这又引入一个问题,线程池大小多少合适?需要判断操作是CPU密集型还是IO密集型,如果是CPU密集任务,线程池和cpu合数一样即可,如果是IO密集型,因为IO等待会释放CPU资源,所以可以大一点,但具体还是通过不断的性能测试调整较为合适。

一般来说,可以根据这一一个结论判断:N核服务器,本地计算时间为x,等待时间为y,则工作线程数设置为 N*(x+y)/x。


[1]. 《七周七并发》

CONTENTS