安全性问题
事实上,并不是所用的情况用多线程就一定比单线程快。多线程如果使用不当,不仅会带来严重的安全性问题,也会造成性能问题。
在JVM内存结构中,我们知道栈、程序计数器、局部变量这些是线程私有的,但是进程范围内的资源,同一个进程内的线程都会共享进程内的地址空间,那么它们会共享堆上分配的对象。当多个线程并发运行时,它们就可能会访问或者修改其他线程正在使用的变量造成严重的后果,比如我们在JMM笔记中提到的read-modify-write。那么在并发编程中,如果我们可以对要访问的资源的可见性、有序性、原子性都加以保证,那么确实可以解决安全性问题,但是多线程以及JVM的编译优化给我们带来的好处就无法享受了,而事实上,我们可以由多种同步机制来维护并发编程中的数据安全,并不是所有的场景都需要无脑的加锁,我们完全可以根据我们的场景我们的需求,选择最适合我们的方案。
互斥 最容易想到的在多线程编程下,对资源的保护那就是采用互斥的手段。所谓互斥,即同一时刻只有一个线程可以拿到这个资源。
临界区 我们把一段需要互斥执行的代码就称为临界区
互斥的两种方案
在操作系统种,对于进程互斥有两种实现方案:信号量与管程。事实上,它们只是两种方案,并不是实现。你完全可以用信号量去实现管程,也可以用管称去实现信号量。
信号量以下内容部分摘自《操作系统–内核与设计》
信号量,可以理解为有几张通行证的思想。简单的来说,信号量维护一个计数器和等待队列,这个信号量还会有三个方法:初始化、P和V,P和V分别是荷兰语的test(proberen尝试)和increment(verhogen增量),P方法和V方法可以理解为尝试获取资源与归还资源。
当一个线程请求访问受到保护的临界区,这个线程首先要去尝试获取资源。怎么尝试呢,信号量就会先把计数器-1,然后查看当前计数器的值是否>0,如果>=0,就表明你可以获取资源;如果<0,就表示资源已经被人用完了,不好意思你要去等待队列等一等了。线程执行完业务逻辑,要归还资源,这时候信号量就会把计数器+1,如果当前计数器的值<=0,就表示等待队列还有人在等候,那么它就会去等待队列里唤醒一个线程,让它去重新尝试获取资源。
内部它可以用下面的伪代码简单表达实现思路:
public class FakeSemaphore {
SemaphoreStruct semaphoreStruct;
public FakeSemaphore(SemaphoreStruct semaphoreStruct) {
this.semaphoreStruct = semaphoreStruct;
}
//获取资源
public void up() {
//计数器--
semaphoreStruct.count.getAndDecrement();
if (semaphoreStruct.count.get() < 0) {
//线程阻塞
//将线程插入到阻塞队列末尾
//重新调度
}
}
//释放资源
public void down() {
//计数器++
semaphoreStruct.count.getAndIncrement();
if (semaphoreStruct.count.get() <= 0) {
//从阻塞队列中唤醒一个线程
}
}
}
class SemaphoreStruct {
//事实上,如果count=1就可以用信号量实现线程互斥,count>1,就可以实现线程同步
public AtomicInteger count = new AtomicInteger();
public BlockingQueue queue;
public SemaphoreStruct() {
this.count.set(1);
this.queue = new linkedBlockingDeque();
}
}
信号量的count设计你会发现很巧妙,当count设计为1,就可以解决线程的互斥问题;当count设计>1,就可以解决线程间的同步问题。
管程这里只描述了信号量的简单设计,事实上jdk中已经有信号量的工具类java.util.concurrent.Semaphore
插个题外话,信号量的设计者Dijkstra也是图论中最短路径的大佬。膜拜一下。
管程也是一种互斥同步的解决方案,它其实就是将共享变量于对共享变量的操作封装起来,外部只能通过管程暴露的方法对共享变量进行操作。
互斥:
管称是互斥进入的
同步:
在管程中设置条件变量及等待唤醒操作解决同步问题,让一个线程在不满足条件变量时进行等待,释放CPU使用权,其它线程执行时发现条件满足,再对它进行唤醒
举个栗子,管程现在保护资源A,线程1来了发现条件不满足,于是执行到了一半sleep,让出CPU使用权,然后线程2执行了一半,发现条件满足了,对线程1进行唤醒,这时就有三种方案:
线程2等待线程1执行完再继续执行
线程1等待线程2执行完再执行
规定唤醒为管程中最后一个可执行的操作
以上三种方案就是管程的三种模型:Horae、Mesa、Hansen。Java语言中的管程使用的是第二种方案。
总结在字节码指令中,管程是通过monitor实现的。
信号量与管程都是同步互斥的解决方案,信号量使用场景更广泛一点,设计也更巧妙一点。如果计数器=1,就可以用信号量实现互斥,如果计数器>1,就可以用作线程之间的同步(协作)。但是信号量的使用难度也大一点,如果PV方法使用不当,就可能造成等待队列中的线程永远不会被唤醒。
管程则更适合需要临界区的执行需要满足一定条件的情况,管程模型有两个队列,一个是等待访问临界区的等待队列,一个是等待满足条件的条件队列。线程想访问临界区,首先要去等待队列等候,轮到它访问临界区了,那么它执行的时候还需要看它是否需要满足一定条件,如果不满足,就去等待队列等候,等候别的线程执行的时候发现满足条件了再唤醒它。
在Java中,有两种管程实现方案:synchronized与wait、notify;lock+condition,Java的管程实现采用了Mesa模型,也就是线程1不满足条件进入等待状态,线程2唤醒线程1后,线程1重新去等待队列排队,并且线程2执行完才能轮到下一个线程去访问临界区。
java中的管程实现 synchronized与wait、notify
被synchronized保护的资源的模型如下:
测试一: 测试目的
证明线程1进入wait阻塞后,线程2唤醒线程1,线程2会继续执行完之后线程1才可以继续执行
测试代码public class MonitorTest1 {
private static Object ob1 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
synchronized (ob1) {
System.out.println(Thread.currentThread().getName() +
"获取到ob1资源啦=======");
try {
ob1.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() +
"执行完释放ob1资源啦=======");
}
});
Thread thread2 = new Thread(() -> {
synchronized (ob1) {
System.out.println(Thread.currentThread().getName() +
"获取到ob1资源啦=======");
System.out.println(Thread.currentThread().getName() +
"条件满足,我去唤醒其它线程=======");
ob1.notifyAll();
System.out.println(Thread.currentThread().getName() +
"执行完释放ob1资源啦=======");
}
});
thread1.start();
Thread.sleep(100);
thread2.start();
}
}
测试结果
结果分析
可以看到Thread-0执行到一半发现条件不满足,跑到等待队列去等待并让出CPU资源,这个时候Thread-1进来,发现条件满足了,可以唤醒其它线程,这时Thread-0线程被唤醒,但是Thread-1依旧继续执行,直到Thread-1执行完毕让出CPU资源,Thread-0线程才可以继续执行,证明Java的管程模型确实是Mesa模型。
lock+condition
简单的写栗子演示下:
测试代码public class MonitorTest3 {
public static void main(String[] args) throws InterruptedException {
ConditionTest conditionTest=new ConditionTest();
Thread thread = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
conditionTest.method2();
});
thread.start();
conditionTest.method1();
}
}
class ConditionTest{
private ReentrantLock reentrantLock = new ReentrantLock();
private Condition condition = reentrantLock.newCondition();
void method1() throws InterruptedException {
reentrantLock.lock();
try {
System.out.println("条件不满足,开始等待");
condition.await();
System.out.println("条件满足,开始继续执行");
}finally {
reentrantLock.unlock();
}
}
void method2(){
reentrantLock.lock();
try {
System.out.println("条件满足,唤醒其它线程");
condition.signal();
}finally {
reentrantLock.unlock();
}
}
}
测试结果
活跃性问题
我们通过加锁来保证并发编程的安全性,但是如果我们滥用锁,往往会导致另外一个严重的问题—活跃性问题,如死锁,活锁,饥饿。
死锁 死锁这个从概念上很好理解,比如有两把锁,锁A和锁B。线程1持有锁A,在等待锁B,线程2持有锁B,在等待锁A。这个时候这两个线程就会进入遥遥无期的等待环节,诶谁也别服输。
举个通俗易懂的例子,你跟你女朋友吵架了,你女朋友在等你道歉,你在等你女朋友道歉,好家伙,那你们可以直接say godbye了。怎么解决呢,好办啊,你先低个头,完事儿。
官方一点的定义是:一组互相竞争资源的线程因互相等待,导致"永久"阻塞的现象
下面用个demo演示下死锁:
public class DeadLockDemo {
private static Object object1 = new Object();
private static Object object2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (object1) {
System.out.println(Thread.currentThread().getName() + "获取到了锁1" +
",尝试获取锁2");
synchronized (object2) {
System.out.println(Thread.currentThread().getName() +
"获取到了锁2");
}
System.out.println(Thread.currentThread().getName() + "释放了锁2");
}
System.out.println(Thread.currentThread().getName() + "释放了锁1");
});
Thread thread2 = new Thread(() -> {
synchronized (object2) {
System.out.println(Thread.currentThread().getName() + "获取到了锁2" +
",尝试获取锁1");
synchronized (object1) {
System.out.println(Thread.currentThread().getName() + "获取到了锁1" );
}
System.out.println(Thread.currentThread().getName() + "释放了锁1");
}
System.out.println(Thread.currentThread().getName() + "释放了锁2");
});
thread1.start();
thread2.start();
}
}
测试结果:
Dijkstra提出并解决的哲学家就餐问题是经典的进程同步问题,没错,又是这位大佬,先给他跪了orz。哲学家就餐问题描述如下:
有5个哲学家共用一张圆桌,分别坐在周围的5张椅子上,在圆桌上有5只筷子,他们的生活方式是交替地进行思考和进餐。平时,每个哲学家进行思考,饥饿时便试图拿起其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。
那么一到吃饭的时间12点了,五个哲学家同时拿起自己左手边的筷子,完蛋,他们5个都会进入漫长的等待直到饿死,这可真是个悲伤的故事。
银行转账问题 假设你要给你的女朋友转500元,让她买两根口红。这个时候银行先把你的账户锁住了,试图去锁定你女朋友的账户给她转钱,然后恰巧这个时候你的女朋友也要给你转账50,让你吃顿饭,银行这个时候刚锁住她的账户,准备去锁定你的账户,那完蛋了,银行崩了。
造成死锁的4个条件 从上面的两个案例,我们可以看出造成死锁有四个必要条件:
- 互斥,共享资源X和Y只能被一个线程占用
- 占有且等待,线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X
- 不可抢占,其他线程不能强行抢占线程T1占有的资源
- 循环等待,线程T1等待线程T2占有的资源,线程T2等待线程T1的战友的资源,就是循环等待
上面的四个必要条件,我们任意破坏掉一个就可以避免掉死锁了。
互斥我们是没办法破坏的,但是其他三个好办:
-
占有且等待:我们可以一次性申请所有的资源,这样就不会等待
-
不可抢占:占有部分资源的线程进一步申请其他资源时,如果申请不到,就释放自己占有的资源
-
循环等待:按顺序申请资源,当然,这仅仅针对资源是线性有顺序的而言
我们用刚死锁的解决方案映射到哲学家问题上,看下怎么解决:
- 占有且等待:吃饭的时候,哲学家首先尝试一次性拿起左手的筷子跟右手的筷子再吃饭(可以用信号量),拿不到他就不吃了
- 不可抢占:哲学家吃饭的时候呢,先拿起左手边的筷子,然后想拿右手边的筷子,过了一分钟发现还拿不到,哲学家很生气,筷子一摔,老子不吃了!这个时候他旁边的那个幸运的小伙伴就可以吃了:)
- 循环等待:给哲学家的座位排个序号,然后按照哲学家座位的序号给他们的筷子也排个序号,然后按照筷子的序号去获取吃饭。(其实这个用银行转账的更好理解点:将要转账的账户A跟B,它们在银行系统里肯定有个主键ID,锁定账户的时候按照id来锁定,那么你跟你女朋友同时转账也无所谓了)
- 避免策略:这个其实是从问题的根本上
这里测试的例子是死锁demo
arthas 程序跑起来后,进入arthas,然后执行thread,就可以看到当前线程及状态:
[arthas@11288]$ thread Threads Total: 22, NEW: 0, RUNNABLE: 9, BLOCKED: 2, WAITING: 4, TIMED_WAITING: 3, TERMINATED: 0, Internal threads: 4 ID NAME GROUP PRIORITY STATE %CPU DELTA_TIM TIME INTERRUPT DAEMON 2 Reference Handler system 10 WAITING 0.0 0.000 0:0.015 false true 3 Finalizer system 8 WAITING 0.0 0.000 0:0.015 false true 4 Signal Dispatcher system 9 RUNNABLE 0.0 0.000 0:0.000 false true 5 Attach Listener system 5 RUNNABLE 0.0 0.000 0:0.062 false true 13 arthas-timer system 5 WAITING 0.0 0.000 0:0.015 false true 15 Keep-Alive-Timer system 8 TIMED_WA 0.0 0.000 0:0.000 false true 16 arthas-NettyHttpTelnetBootstr system 5 RUNNABLE 0.0 0.000 0:0.031 false true 17 arthas-NettyWebsocketTtyBoots system 5 RUNNABLE 0.0 0.000 0:0.015 false true 18 arthas-NettyWebsocketTtyBoots system 5 RUNNABLE 0.0 0.000 0:0.000 false true 19 arthas-shell-server system 5 TIMED_WA 0.0 0.000 0:0.000 false true 20 arthas-session-manager system 5 TIMED_WA 0.0 0.000 0:0.000 false true 21 arthas-UserStat system 5 WAITING 0.0 0.000 0:0.000 false true 23 arthas-NettyHttpTelnetBootstr system 5 RUNNABLE 0.0 0.000 0:0.140 false true 24 arthas-command-execute system 5 RUNNABLE 0.0 0.000 0:0.000 false true 6 Monitor Ctrl-Break main 5 RUNNABLE 0.0 0.000 0:0.015 false true 9 Thread-0 main 5 BLOCKED 0.0 0.000 0:0.000 false false 10 Thread-1 main 5 BLOCKED 0.0 0.000 0:0.000 false false 11 DestroyJavaVM main 5 RUNNABLE 0.0 0.000 0:0.234 false false -1 VM Periodic Task Thread - -1 - 0.0 0.000 0:0.000 false true -1 C1 CompilerThread0 - -1 - 0.0 0.000 0:0.359 false true -1 VM Thread - -1 - 0.0 0.000 0:0.093 false true -1 Service Thread - -1 - 0.0 0.000 0:0.000 false true
上面其实就展示出了两个阻塞BLOCKED状态的线程,但是这个可能不太直接,我们再执行下thread -b命令:
[arthas@11288]$ thread -b
"Thread-1" Id=10 BLOCKED on java.lang.Object@1964bae owned by "Thread-0" Id=9
at com.designpattern.demo.thread.lock.DeadLockDemo.lambda$main$1(DeadLockDemo.java:31)
- blocked on java.lang.Object@1964bae
- locked java.lang.Object@cc09a <---- but blocks 1 other threads!
at com.designpattern.demo.thread.lock.DeadLockDemo$$Lambda$2/1539789.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
看,直接连有问题的地方都标注出来了,是不是很优秀?
没错,这里强力安利arthas,真的是非常牛逼的一个工具了!
jstack 首先,找出程序的pid,这里是6604
然后命令行执行jstack -l pid:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x15b0b094 (object 0x057e39a0, a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x15b0cb44 (object 0x057e39a8, a java.lang.Object),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at com.designpattern.demo.thread.lock.DeadLockDemo.lambda$main$1(DeadLockDemo.java:31)
- waiting to lock <0x057e39a0> (a java.lang.Object)
- locked <0x057e39a8> (a java.lang.Object)
at com.designpattern.demo.thread.lock.DeadLockDemo$$Lambda$2/1539789.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
"Thread-0":
at com.designpattern.demo.thread.lock.DeadLockDemo.lambda$main$0(DeadLockDemo.java:19)
- waiting to lock <0x057e39a8> (a java.lang.Object)
- locked <0x057e39a0> (a java.lang.Object)
at com.designpattern.demo.thread.lock.DeadLockDemo$$Lambda$1/17673857.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
Found 1 deadlock.
可以看到,jstack一样提示出了死锁的位置和代码行。
ThreadMXBean 这个比较特殊,它是个接口,这就意味着我们可以在开发过程中直接进行检测
测试代码:
public class DeadLockDemo {
private static Object object1 = new Object();
private static Object object2 = new Object();
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
synchronized (object1) {
System.out.println(Thread.currentThread().getName() + "获取到了锁1" +
",尝试获取锁2");
synchronized (object2) {
System.out.println(Thread.currentThread().getName() +
"获取到了锁2");
}
System.out.println(Thread.currentThread().getName() + "释放了锁2");
}
System.out.println(Thread.currentThread().getName() + "释放了锁1");
});
Thread thread2 = new Thread(() -> {
synchronized (object2) {
System.out.println(Thread.currentThread().getName() + "获取到了锁2" +
",尝试获取锁1");
synchronized (object1) {
System.out.println(Thread.currentThread().getName() + "获取到了锁1");
}
System.out.println(Thread.currentThread().getName() + "释放了锁1");
}
System.out.println(Thread.currentThread().getName() + "释放了锁2");
});
thread1.start();
thread2.start();
Thread.sleep(500);
//增加ThreadMXBean
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
long[] deadlockedThreads = threadMXBean.findDeadlockedThreads();
//如果发现了死锁
if (deadlockedThreads != null && deadlockedThreads.length > 0) {
for (long i:deadlockedThreads){
ThreadInfo threadInfo=threadMXBean.getThreadInfo(i);
//这里的处理方式是直接打印出死锁的线程名称
System.out.println("发现死锁:"+threadInfo.getThreadName());
}
}
}
}
测试结果:
活锁 活锁通常发生在处理事务消息的应用程序中,如果不能成功的处理某个消息,那么消息处理机制就会回滚整个事务,并且重新将它放在队列当开头,处理起就会反复调用,每次都返回相同的结果。
这种问题的处理办法是在重试机制中引入随机性,通过等待随机长度的时间和回退可以有效的避免活锁现象。
饥饿 饥饿指的是线程由于无法访问它所需要的资源而不能继续执行。
比如在并发编程时,我们队优先级使用不当,或者持有锁时执行一些无法结束的错误代码,都可能会导致饥饿。当然,Thread属性中的优先级知识个参考,不同的操作系统对优先级的映射首先不同,而且操作系统也有权利更改优先级设置。
性能问题
线程最初的目的是为了提高程序的运行性能。但是,如果我们使用不当,反而会影响程序的性能。
与单线程的程序相比,多线程会引入额外的性能开销,如:线程之间的协调、增加的上下文切换、线程的创建及销毁,线程的调度,内存同步等等。如果过度的使用线程,那么这些开销甚至会超过提高吞吐量、响应行或者计算能力带来的提升。
我们使用线程,通常是为了充分的利用多个处理器的计算能力,因此我们会将更多的侧重点放在吞吐量和可伸缩性上,而Amdahl定律告诉我们,程序的可伸缩性取决于所有代码中必须被串行的代码比例。在Java程序中,串行操作的主要来源是独占方式的资源锁,因此,通常可以通过以下几种方式提高可伸缩性:
- 减少锁持有时间
- 降低锁力度
- 采用非独占的锁或者非阻塞锁来代替独占锁
几乎在所有的事情都涉及到某些形式的权衡,几乎没有什么事情是只有利,没有弊的。在并发编程中,如果各种同步措施使用得当,我们可以提高程序的吞吐量,如果使用不当,反而会浪费性能甚至造成死锁等严重的问题。所以,我们要理性分析我们程序要做的事情,权衡是否需要多线程开发,然后再根据我们的场景选择具体使用什么样的同步措施。



