一、基本概念
并发和并行
并发是单个cpu,偏重于多个任务的交替进行,重点在于不断地切换任务,以达到并行的效果,其实是串行进行的。
并行是真正的并行,是多个cpu同时工作,任务是并行执行的,省去了任务切换的时间,大大提高了效率。
临界区
临界区的就是公共资源区,被所有线程共同分享的区域,在Java内存模型中,java堆就是典型的临界区。
阻塞和非阻塞
阻塞是指在线程在执行时由于cpu资源或者临界区资源的权限拿不到而导致的线程的停止等待。
非阻塞是指一个线程不受这些资源的影响,可以不断的执行程序。
死锁,饥饿,活锁
死锁是指多个线程在资源的抢夺时出现的一种阻塞现象,如果没有外力的作用将会永久的持续下去。
饥饿是指线程在资源的抢夺过程中由于种种原因导致一直未能得到资源而产生的一种不能执行的状态。
活锁是指在资源的抢夺过程中线程能够自己解决的一种阻塞现象,这种阻塞是可以自发的解决。
二、多线程容易产生的三大问题
原子性问题,可见性问题,有序性问题,是在多线程中容易出现的问题,也是造成线程不安全的问题,所以要想使用多线程必须要注意这些问题。要解决这些问题那就是要在多线程中保证 原子性,可见性,一致性。
原子性:是指一个线程的操作在运行的过程中不能被中断,执行就执行完毕,不存在中间的状态,其他线程不能干扰。
public class Demo1 {
int count = 0;
public void add() {
System.out.println(Thread.currentThread().getName());
for(int i=1; i<=10000; i++){
count++;
}
}
public static void main(String[] args) {
Demo1 demo1 = new Demo1();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
demo1.add();
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
demo1.add();
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(demo1.count);
}
}
运行结果是什么样的呢? 20000?,事实是结果总是一个小于20000的数值。
看了很多博客都是以转账为例,比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了1000元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。就会造成数据的不一致性。
原子性的理解
这里的原子性怎么理解呢?刚开始我看这个例子很迷惑。
现在我说一下我的理解,原子性就是对某个数据或者某些业务逻辑看成是一步完成的,不存在中间的某个状态,对于账户A向账户B转1000元那个例子来说,只会存在两种情况,1. 不转账。 2. 转账成功 A账户 -1000元,B账户 +1000元。这个例子还是不好懂的话,请看下一个。
对于上面的代码:count++; 。 对于这段代码这是一个非原子性的操作,它分为两步 1. 取count的值。 2. count+1。 3. 将 count+1 赋值给 count。这种操作就会出现线程安全问题,例如A线程正在执行该条语句 假如此时count为4,当A线程执行到count+1=5,但是还未赋值给count,此时B线程也执行该语句,去拿count的值,这时count还是4,A线程同时也在继续向下执行,当A执行完毕时count为5,B线程也继续执行完毕,并将5赋值给count,此时count为5,但是正确的结果应该为6,就得到了错误的结果,存在线程安全,这就是原子性问题。
非原子性 (图描述)两个线程在不同时刻拿到相同的值进行相同操作。
原子性只存在两种:1. A先执行count++,完成后B执行count++。2. B先执行count++,完成后A执行count++。
A先执行
B先执行
解决原子性问题就是解决怎么把不原子性的操作变为原子性操作,这种变化是不可能完成的,但是可以从另一个方面解决,那就是在有一个线程执行该操作时,不允许其他线程进入,使进入该操作时就串行执行,这样不原子性的操作就可以原子性的执行完毕,就可以认为是原子性的。
Synchronized和Lock加锁都可以解决原子性问题。
可见性是指在多个线程工作时,一个线程对于临界区资源的修改,或者修改后的数据对其他线程是可见的,当其他线程得知自己目前拿到的数据已经不是最新的数据时会释放掉此时拿到的数据,保证数据的准确性。
public class Demo1 {
final Object object = new Object();
int count = 0;
boolean b = true;
public void add() {
System.out.println(Thread.currentThread().getName());
while(b) {
}
}
public static void main(String[] args) throws Exception {
Demo1 demo1 = new Demo1();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
demo1.add();
}
});
t1.start();
Thread.sleep(100);
demo1.b=false;
t1.join();
System.out.println("运行结束");
}
}
结果发现即使将 b重新赋值为false 但线程t1并未结束,而是一直循环。出现问题是因为线程与线程之间不可见,也就是一个线程不知道另一个线程的存在。当主线程将b设置为false后,只是改变了物理内存b在主线程工作空间(线程私有的)的b的一个副本,在将来的某个时刻,会将b的副本刷新到物理内存中,t1线程在执行过程中也是只去自己的工作空间中取b的值,所以导致b在不同线程中的版本不同。
解决可见性问题:
1. 将具有时效性的变量设置为 volitile 的,原理是当线程使用该变量时,修改时能立即执行store指令将修改后的值刷新到物理内存,其他的线程当需要对当前volitile变量进行操作时,会执行load指令去主存中取读取数据,如果对其进行了修改,也是需要执行store指令立即刷新到主存。
2. 使用Synchronized和Lock加锁,在程序释放锁之前会将工作空间的数据刷新到物理内存。使用加锁时,基本过程是:获取锁资源,清空工作空间内存,将主存中的最新值读取到工作内存,对工作空间的只进行操作,将修改后的值刷新到主存,释放锁资源。
有序性是指在程序的运行过程中出现指令的重排,意思就是写在前面的代码后执行,写在后面的代码提前执行,指令重排的目的是减少中断浪费的时间,原则是不影响程序最后得到的结果,方式根据代码与代码之间的依赖性判断是否可以重排。具体规则可以参考https://blog.csdn.net/liu_dong_liang/article/details/80391040。
volatile可以禁止指令重排,在对volatile操作的指令执行时会出现一个内存屏障,屏障之前的指令不能指令重排到屏障之后执行,屏障之后的指令不能指令重排到指令之前执行。