什么是并发?
并发是在同一个时间段内,同时可以处理多个事件,从而提高资源的利用率;并通过不同粒度的时间分片以保证通过用户和程序在不同的时间段内可以享有计算机上的资源的使用权;并使每个程序只执行一个任务,且只在必要时进行通信,从而简化编程。
多线程的优势
1、多线程可以有效的降低程序的开发和维护成本,同时提高复杂应用程序的性能。
2、当线程数多于cpu核心数时,通过时间分片的方式使大部分的异步工作流转换成串行工作流, 更好的模拟人类的工作方式,方便理解,也更容易编写、阅读和维护。
多线程带来的风险
1、安全性问题
2、活跃性问题
3、性能问题
安全性问题:
线程安全性指的是某个类的行为与其规范完全一致。在没有充足同步情况下,多个线程中的操作执行顺序是不可预测的,其中常见的安全问题包含两种:竞态条件和数据竞争。
竞态条件:是指由于不恰当的时序执行时读写共享资源时导致意想不到的结果。在计算的正确性取决于多线程的交替执行时序时往往会产生竞态条件。(可以简单的理解为正确的运行结果依赖于事件发生的先后顺序)
例子:先检查后执行的竞态条件。首先检查某个条件是否成立,如果成立则执行正确的相应动作,否则导致各种问题(包括意料之外的异常、数据被覆盖、文件被破坏等)
@NotThreadSafe
class LazyInitRace{
private Object instance = null;
public Object getInstance(){
if(instance == null) {
instance = new Object();
}
return instance;
}
}
在没有同步机制协调的情况下如果此时有两个线程,线程A和线程B同时执行getInstance,则两个线程都会读取到instance的状态,由于时序的不同,可能出现三种情况。
情况一:如果线程A先执行getInstance,得到instance为空,然后进行初始化,然后B线程读取instance的状态如果instance不为空则不用进行初始化。此种情况设定为正常运行的状态。
情况二:反过来线程B先执行,即B线程对instance进行初始化,得到的为正确结果
情况三:A线程先执行getInstance(),但由于A线程需要花费大量时间初始化Object对象并设置instance变量,在这期间内B线程也执行了getInstance()也进行了初始化工作,由于执行线程不同可能初始化参数不同导致getInstance返回的结果不同,即使返回相同的实例我们也称之为破坏了安全性。
数据竞争:在访问共享的非final类型的域时没有采用同步来进行协调,与竞态条件的区别在于是对数据的使用权的竞争。(后续介绍)
为了实现多线程对共享变量安全的访问,也可以说是保证一组针对共享变量操作的原子性、有序性java提供了一些同步协调机制保证对共享变量的安全访问,包括加锁、原子类、原子操作等。
原子操作是指,对于访问同一状态的所有操作(包括操作本身)都是以原子方式执行的操作,典型的原子操作比如读取、修改、写入。这些操作在执行的过程中不能被其他线程打断。在程序中一般都会用一组需要以原子方式执行的操作,虽然每个原子操作是线程安全的,但是一组原子操作(也称为复合操作)可能因为不恰当的执行时序而存在竞态条件问题,从而无法保证并发安全问题。为了避免竞态条件问题,就必须在某个线程修改该变量时其他线程不能使用该变量,从而保证其他线程只能读取该变量修改之前的状态和修改后的状态。
针对保证复合操作的原子性有两种方式可以解决,1、通过加锁机制2、使用原子类。
原子类可以参考这篇blog,感觉介绍的挺好的。
Java16个原子类介绍-基于JDK8_晏霖/胖虎的博客-CSDN博客_原子类
而加锁机制可以分为显式锁和内置锁
其中内置锁分为重量级锁、轻量级锁、偏向锁、自旋锁、自适应自旋锁、可重入锁等。在编程实现时用synchronized或者ReentrantLock关键字修饰代码块或者方法的方式进行加锁来支持原子性操作。
例子:java的内置锁包含两部分:锁对象和锁保护的方法,其中锁对象锁住的是当前类的实例对象即默认为对this进行加锁,synchronized修饰方法,这种方式虽然是线程安全的但是过于极端,多个线程无法同时使用该方法从而导致性能的降低。
@NotThreadSafe
class LazyInitRace{
private Object instance = null;
public synchronized Object getInstance(){
if(instance == null) {
instance = new Object();
}
return instance;
}
}
下面的代码是用synchronized修饰的同步代码块,包含锁住的对象和锁保护的代码块。锁住的对象可以是this也可以是用户指定的对象,即每个java对象都可以用作一个实现同步的锁,而这些锁也就是内置锁,获取内置锁的唯一途径就是进入这个锁保护的同步代码块或方法。对于被锁住的代码要求在任一时刻只能有一个线程可以获取这把锁(这里先理解什么是锁,这里的概念主要是针对独占锁,小编也是在学习中不对的地方可以留言讨论,小编也会不断的修改和更新)下面就是简单的用同步代码块样例
@NotThreadSafe
class LazyInitRace{
private Object instance = null;
public Object getInstance(){
synchronized(this){
if(instance == null) {
instance = new Object();
}
}
return instance;
}
}
在实现了锁机制后,保证了每一时刻只能有一个线程获取这把锁,但如果该线程不释放资源则包括自身在内的所有线程都得等待该资源的释放,效率较低。为了提高并发的性能引入了一个可重入的概念。
可重入就是如果某个线程尝试获得一个已经由他自己持有的锁,那么该线程可以再次获取这把锁。重入的一种实现方式就是让每个锁对象关联一个获取计数值和一个所有者线程,首先判断是否是持有该资源的线程,如果是则每次持有该资源则计数值+1,释放一次该资源则计数值-1。当计数值为0时就释放锁。
重入的优点:进一步提升了加锁行为的封装性,并避免了一种子类调用父类的方法时导致死锁的情况发生。
下面就着重介绍一下该种情况的死锁。首先来看代码:
public class Widget{
public synchronized void doSomething(){
}
}
public class LoggingWidget extends Widget{
public synchronized void doSomething(){
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
在程序中子类改写了父类中的方法,然后在方法中调用了父类的方法时会锁住同一个对象即子类对象(为什么是一个对象呢?编程测试的确 是同一个对象,待小编好好理一下,看看源码再更新),如果锁不可重入则在这里就出现了该线程等待自己释放锁,从而线程将永远的等待下去,就出现死锁了。重入则避免了这种情况的产生。