在前面的学习中, 我们在创建线程的时候, 都是会在线程中指定一个任务, 其中就可以使用 Runnable 接口来实现(在前面文章中我所写的代码都是使用 lambda 表达式, 因为写起来更加简洁, 但其实本质都是一样的), 但是使用 Runnable 接口来指定任务的时候是没有带返回值的. 那么如果我们想要有带返回值的任务的时候, 应该怎么做呢?
这时候就出现了 Callable 接口, 和 Runnable 接口一样的, 在创建线程的时候都可以用来指定一个具体的任务, 但是它比 Runnable 接口多了一个在指定任务的时候是带返回值的.
例如使用 Callable 接口实现1+2+…+1000的操作:
public class Main {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for(int i = 1; i <= 1000; i++){
sum += i;
}
return sum;
}
};
FutureTask<Integer> task = new FutureTask<>(callable);
Thread thread = new Thread(task);
thread.start();
System.out.println(task.get());
}
}
当然, 通过上面这段代码观察之后发现: 使用 Callable 接口来创建线程的时候, 是不能像 Runnable 接口那样 Thread thread = new Thread(runnable)
直接将其作为一个参数传进去, 而是只能配合 FutureTask
间接地将 task 作为参数传过去, 而 FutureTask
存在的作用就是负责等待并保存 Callable 返回的结果.
介绍完 Callable 接口, 我们就可以来简单总结一下从前面到现在的几种创建线程的方法(详细可看前面文章):
很多人好奇JUC是什么东西, 其实它的全称叫做
java.util.concurrent
, 实际上就是一个包, 在使用一些类的时候需要导入的一个包.
先简单带过 ReentrantLock 类的基本内容, ReentrantLock 也是一种锁: 1. 可以使用 lock() 方法来进行加锁操作; 2. 可以使用 tryLock() 方法来指定等待获取锁的最长时间, 如果超过这个时间那么就会自动放弃加锁操作; 3. 可以使用 unlock() 方法来进行解锁操作.
public class Main {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.tryLock();
reentrantLock.unlock();
}
}
一说到锁啊, 相信看过之前文章的同学会马上想到 synchronized, 毕竟这可是多线程内容的重点. 那么为什么在有 synchronized 的情况下还要有其他锁呢? 接下来, 就来总结这两种锁之间的区别:
相同点:
不同点:
综上所述: 当锁竞争不激烈的时候, 使用 synchronized 来进行修饰效率会更高, 锁也会自动进行释放; 当锁竞争比较激烈的时候则使用 ReentrantLock 来搭配 tryLock() 方法灵活地控制加锁行为, 不会轻易地出现死等的情况.
Java中的原子类有很多(AtomicBoolean, AtomicInteger, AtomicIntegerArray, AtomicLong, AtomicReference, AtomicStampedReference …), 但是这些都不需要刻意去记, 当需要使用到的时候再查文档即可.
注意: 原子类内部使用的是上一篇文章中总结的CAS来实现的, 所以在性能方面是要比加锁实现++操作更高一些的.
public class Demo17 {
private static AtomicInteger count = new AtomicInteger();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
for(int i = 0; i < 5000; i++){
count.getAndIncrement();
}
});
Thread thread2 = new Thread(() -> {
for(int i = 0; i < 5000; i++){
count.getAndIncrement();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("count = " + count);
}
}
前面文章中已经有总结过一部分的线程池相关内容, 这里再对线程池一些其他的知识点做一个补充.
创建线程池的几种方式:
ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(3);
ScheduledExecutorService singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor();
ScheduledExecutorService scheduledThreadPool = Executors.newScheduledThreadPool(3);
Executors.newWorkStealingPool();
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(3, 10, 10000, TimeUnit.MILLISECONDS, new LinkedBlockingQueue());
ThreadPoolExecutor 类是线程池中最核心的线程池类, 为了更好地掌控代码, 在实际开发中一般都会倾向于选择ThreadPoolExecutor 类来创建线程池, 将这种定义方式也称为是自定义线程池, 比较灵活.
总结ThreadPoolExecutor 类中的参数:
先在IDEA中查看对应的源码:
那么上面使用ThreadPoolExecutor 类在填写参数的时候 corePoolSize 和 maximumPoolSize 都应该填多少合适呢?
其实这是没有一个准确的答案的, 因为在不同的机器下, 不同的环境下, CPU占用, 内存占用都是会不一样的. 对此我们通常情况下都会采用测压的方式来进行确定, 也就是对当前程序进行性能测试, 分别设置不同的线程数量进行测试操作. 当然, 这对CPU密集型的程序和对IO密集型的程序又会有所区别, 但是总而言之, 在确定线程数的时候最好都是进行一个测压操作.
信号量(Semaphore)是用来表示"可用资源的个数"的, 其本质上就是一个计数器.
举一个形象的例子: 有n个座位(表示可用资源空间为n), 当有人坐上去(表示n-1, 申请一个可用资源, 信号量-=1, 称为是P操作), 当有人离开这个座位(表示n+1, 释放一个可用资源, 信号量+=1, 称为是V操作), 其中这些 +=, -= 的操作都是原子的, 也即是说 Semaphore 是可以直接使用于多线程安全的控制中, 当 n 变为0的时候, 说明可用资源空间已经满了, 这时候如果再有要申请资源的, 就需要进行阻塞等待了.
实际上, 我们也可以把信号量视为是一个广义的锁, 因为当信号量的取值是0和1的时候, 它就会退化成是一个普通的锁.
//使用 Semphore 来控制线程安全, 实现两个线程增加同一个变量
public class Main {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
Semaphore semaphore = new Semaphore(1);
for(int i = 0; i < 50; i++){
Thread thread1 = new Thread(() -> {
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
try {
semaphore.acquire();
count++;
semaphore.release();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
System.out.println(count);
}
}
CountDownLatch类: 相当于在一个大任务被拆分成若干个子任务的时候, 用来衡量这些子任务执行结束的时间.
构造CountDownLatch实例, 其参数是表示需要执行任务的个数.
//基于 CountDownLatch 模拟跑步比赛的过程
public class Main {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(10);
for(int i = 0; i < 10; i++){
Thread thread = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println("到达终点");
latch.countDown();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
thread.start();
Thread.sleep(1000);
}
latch.await();
System.out.println("比赛结束");
}
}
在Java标准库中大部分的集合类都是线程不安全的, 例如ArrayList, LinkedList, HashMap, TreeMap, HashSet, TreeSet, StringBuilder等等这些类都时可能会涉及到多线程修改共享数据的, 它们都是没有任何加锁操作的. 对于线程安全的集合类来说, ConcurrentHashMap 和 StringBuffer 是使用的比较多的. 除此之外, 还有一个是不进行加锁但是是线程安全的集合类 String, 因为其不涉及到修改操作.
总结 HashTable, HashMap, ConcurrentHashMap 之间的区别:
在总结这三者的区别之前, 先来详细总结一下 HashTable 和 ConcurrentHashMap 分别是什么.
(1) HashTable: 是线程安全的. 使用 synchronized 直接针对 HashTable 对象本身进行加锁的, 如果多个线程访问同一个 HashTable 就会直接造成锁冲突. 如下图:
从图中不难看出, 它是将锁直接作用在整个对象上, 这样固然是线程安全的, 但是当我们需要对不同哈希桶上的元素进行操作的时候, 会发现这样是不会牵扯到修改同一个变量, 也就是说这样的情况本身就不会出现线程安全的问题, 但是 HashTable 依旧给他加上锁, 这样就会使得锁冲突的概率比较高, 整体效率会下降.
(2) ConcurrentHashMap: 相对于 HashTable, ConcurrentHashMap在其基础上又做了优化 — 锁粒度细化.
将原来的一个大锁细化成了若干个小锁, 当再次访问不同哈希桶上的元素的时候, 也就不会出现锁冲突的情况了(但是其内部还是会出现阻塞等待的情况的), 大大地降低了锁冲突的概率.
此外, ConcurrentHashMap 还做了一些其他的优化, 下面来总结一下 ConcurrentHashMap 的主要优化特点:
最后就是总结三者之间的区别了, 下面会从重要区别到次要区别来进行列举:
死锁是多线程代码中很常见的一种bug, 主要是因为多个线程同时在进行阻塞等待, 它们每个线程都在等其中一个线程释放锁, 但是没有一个线程是会主动来释放锁的, 导致程序进行了无限期的阻塞等待, 进而不能正常终止的情况.
关于死锁主要有以下这几种情况:
(1) 情况一: 一个线程针对同一把锁连续加锁两次, 此时如果这个锁是不可重入锁, 那么就会造成死锁了, 在进入第一个锁后第二个锁之前, 直接在这里进行了阻塞等待. 对于这种情况, 比较好的解决方案是将这个锁修改成可重入锁.
(2) 情况二: 两个线程两把锁, 假如两个线程内部都需要获取到另外一个锁才会释放自己本身的锁的时候, 这时候, 两个线程就会出现互等, 干等的情况, 造成了死锁.
(3) 情况三: N个线程M把锁.这样的情况就会比较复杂了, 可以通过下面"哲学家就餐问题"的例子来理解.
哲学家就餐问题
哲学家就餐问题主要意思就是有N个哲学家(代表的是N个线程, 在当前场景我将其假设为有5个哲学家, 也就是5个线程), 当他们在思考哲学问题的时候, 就代表的是线程休眠; 当他们在进行就餐的时候, 就代表的是线程在CPU上运行. 在他们需要就餐的时候就需要拿起筷子, 而这里的筷子就代表的是一把锁(在当前场景我将其假设为有5根筷子, 也就是5把锁), 然后我们又已知每个哲学家需要就餐的时候, 都必须凑齐两根筷子才能进行就餐(也就是说每一个线程都需要获取到两把锁之后才能够在CPU上运行, 如下图), 如果这5个哲学家在同一时间就餐, 这时候就会出现每个人都要去和相邻的人竞争一根筷子, 谁也不让谁, 从而造成了死等… 那么有没有好的办法来进行解决呢?
我们通过观察上面的这例子, 不难看出造成死锁的一些条件, 在这里总结了四个造成死锁的主要必要条件:
既然找出了出现死锁的原因, 那么通过这些原因就可以找到一写对应的解决方法(重点):
针对多把锁指定一个简单的规则(约定) — 进行编号: 约定在获取多把锁的时候, 是要明确获取到锁的顺序是从小到大的, 也就是说假如当一个线程获取到锁3的时候, 那么这个线程在获取第二个线程的时候只会再继续往后进行获取直到获取到, 而不会出现说返回去获取锁1或者锁2.
接下来就可以来模拟一下这个案例的整个工作流程(建议照着上面的图来进行理解): 每个哲学家(线程)都先往自己的左手边拿筷子(取出编号较小的锁), 由此绕第一圈之后会发现每个哲学家(线程)都手握左筷子(编号小的锁), 最后一个哲学家(线程5)是不会去拿筷子的(没获取到任何锁), 这是因为这个哲学家左右手分别是锁5和锁1, 根据自定义的规则, 这个哲学家必须去尝试获取锁1, 但是锁1此时此刻正在被第一个哲学家使用, 所以最后一个哲学家(线程5)就会继续思考(阻塞等待); 在执行第二圈的时候哲学家1, 2, 3都获取不到自己右手边的筷子(因为被占用了), 直到哲学家4(线程4), 由于哲学家5(线程5)并没有去获取筷子5(锁5), 所以此时筷子5(锁5)就可以顺利地被哲学家4(线程4)获取到… 直到哲学家4(线程4)就餐完毕后会将筷子重新放回桌子上(锁被释放), 此时哲学家3(线程3)就可以获取到哲学家4释放的筷子, 如此递推下去, 最后就可以让每个哲学家都顺利完成就餐(不会出现死锁的情况).