多线程并发编程
并行和并发的概念我们之前有提到过。在回顾下
并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时进行。
那么在多线程编程实战中,线程的个数往往大于CPU的个数,所以一般都称为多线程并发编程而不是多线程并行编程。
在多核CPU时代的到来打破了单核CPU对多线程效能的限制。多个CPU意味着每个线程都可以使用自己的CPU运行,减少了线程上下文切换的开销,随着对应用系统性能和吞吐量的要求,出现了处理海量数据和请求的需求,这些对高并发多线程编程有这急需的要求。
线程安全的问题
并发编程三要素,也是线程的安全性问题所体现。
原子性:一个不可再被分割的颗粒。原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。
可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。
有序性:程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序);
出现线程安全问题的原因
1)线程切换带来的原子性问题。
2)缓存导致的可见性问题。
3)编译优化带来的有序性问题。
解决办法
- JDK Atomic开头的原子类、synchronized、Lock解决原子性问题。
- synchronized、volatile、Lock解决可见性问题。
- Happens-Before 规则可以解决有序性问题。
多线程编程中,有可能会出现多个线程同时访问同一个共享&可变资源的情况,这个资源称之为临界资源。这种资源可能是:对象、变量、文件等。
共享:资源可以由多个线程同时访问。
可变:资源可以在其生命周期内被修改。
由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!
所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。也就是在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
Java 提供了两种方式来实现同步互斥访问:synchronized 和 Lock;
同步器的本质就是加锁;目的就是:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问);
注:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。
那是不是说只要有多个线程去访问同一个资源就一定出现线程安全的问题?答案是否定的。
如果多个线程都是读取这个资源,那就不会出现线程安全的问题。但有一个线程去修改资源了,才会出现线程安全的问题。
synchronized
synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码块不会被多个线程同时执行。synchronized 可以修饰类、方法、变量。
在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。在 Java 6 之后官方从 JVM 层面对 synchronized 进行了较大优化,所以现在的 synchronized 锁效率也优化得很不错了。
JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
synchronized 的使用方式
修饰实例方法:作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
修饰静态方法:也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
修饰代码块:指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
总结:synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!
synchronized 的具体使用
1)双重校验锁实现对象单例(线程安全)
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {}
public static Singleton getUniqueInstance() {
// 先判断对象是否已经实例过,没有实例化过才进入加锁代码
if (uniqueInstance == null) {
// 类对象加锁
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。
uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
- 为 uniqueInstance 分配内存空间
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
synchronized 实现原理
synchronized是Java中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过javap命令,查看相应的字节码文件。