接着上一篇,总结下synchronized、volatile、transient等关键字的用法。
synchronized
- Java语言的关键字,当它用来修饰一个方法或者一个代码块时,能够保证在同一时刻最多只有一个线程执行该段代码,避免线程间产生竞争;
- synchronized是Java的内置锁,依赖于JVM实现,当前线程执行若出现异常,JVM会让当前线程自动释放锁;
- Java允许多线程并发控制,多个线程操作同一个资源变量时,会出现数据不准确,互相冲突等现象,而加入同步锁可以避免线程不安全的情况,可以保证资源变量的准确性和唯一性;
- synchronized有两种用法:即同步方法和同步代码块,可以保证原子性、可见性、有序性(即并发的三特性)。
1、同步方法
synchronized 方法会控制对类成员变量的访问。每个类实例对应一把锁,每个 synchronized 方法都必须获得调用该方法的类实例的锁(对象锁)才能执行,否则所属线程会被阻塞。synchronized 方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,此后被阻塞的线程方能获得该锁,重新进入可执行的状态。
在方法声明中加入 synchronized关键字来声明同步方法,语法格式如下:
// 普通同步方法
public synchronized void methodName1(){ ... }
// 静态同步方法
public static synchronized void methodName2(){ ... }
上面语法格式中同步方法有两种类型:同步实例方法 和 同步静态方法,来测试一下二者的区别:
public class SynchronizedTest {
// 同步实例方法
public synchronized void getName() {
System.out.println(Thread.currentThread().getName() + ":-----------getName start---------");
try {
System.out.println(Thread.currentThread().getName() + ":-----------getName ing...---------");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":-----------getName end---------");
}
// 同步静态方法
public synchronized static void getLog() {
System.out.println(Thread.currentThread().getName() + ":---------》start getLog");
try {
System.out.println(Thread.currentThread().getName() + ":---------》getLog ing...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":---------》getLog end");
}
// 开启多个线程进行测试
public static void main(String[] args) {
// 先测试同步实例方法(注释掉后面的测试同步静态方法)
// Thread t1 = new Thread(() -> new SynchronizedTest().getName());
// Thread t2 = new Thread(() -> new SynchronizedTest().getName());
// Thread t3 = new Thread(() -> new SynchronizedTest().getName());
// t1.start();
// t2.start();
// t3.start();
// 再测试同步静态方法(注释掉前面的测试同步实例方法)
Thread thread1 = new Thread(() -> SynchronizedTest.getLog());
Thread thread2 = new Thread(() -> SynchronizedTest.getLog());
Thread thread3 = new Thread(() -> SynchronizedTest.getLog());
Thread thread4 = new Thread(() -> SynchronizedTest.getLog());
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
开启三个线程,测试同步实例方法的结果如下:(经过了反复多次的测试,随机取一组测试结果)
Thread-0:-----------getName start---------
Thread-2:-----------getName start---------
Thread-1:-----------getName start---------
Thread-1:-----------getName ing...---------
Thread-2:-----------getName ing...---------
Thread-0:-----------getName ing...---------
Thread-2:-----------getName end---------
Thread-0:-----------getName end---------
Thread-1:-----------getName end---------
开启四个线程,测试同步静态方法的结果如下:(经过了反复多次的测试,随机取一组测试结果)
Thread-0:---------》start getLog
Thread-0:---------》getLog ing...
Thread-0:---------》getLog end
Thread-3:---------》start getLog
Thread-3:---------》getLog ing...
Thread-3:---------》getLog end
Thread-2:---------》start getLog
Thread-2:---------》getLog ing...
Thread-2:---------》getLog end
Thread-1:---------》start getLog
Thread-1:---------》getLog ing...
Thread-1:---------》getLog end
同步实例方法的结果中多个线程交叉执行实例方法,而同步静态方法的结果则是多个线程有序执行同步方法(换句话说,同步方法某一时刻只能有一个线程去执行,执行完成后其他线程才能执行)。在main方法测试中可以看到,多个线程执行同步实例方法时,采用各自的当前对象作为锁去执行的,而多个线程执行同步方法时, 使用的是类的class对象作为锁去执行的,这就是二者的区别!!
值得注意的是,同步方法的锁粒度比较粗,若将一个大的方法声明为synchronized 将会大大影响效率。典型地,若将线程类的方法 run() 声明为synchronized,由于在线程的整个生命期内它一直在运行,因此将导致它对本类任何 synchronized 方法的调用都永远不会成功。更好的解决办法是使用 synchronized 块。
2、同步块
在方法的代码块中加入synchronized关键字来声明同步块,格式如下:
// 实例方法的同步块
public void methodName(){
synchronized(syncObject) {
//允许访问控制的代码
}
}
// 静态方法的同步块
public static void methodName(){
synchronized(syncObject) {
//允许访问控制的代码
}
}
改造一下上面同步方法的代码(要特别注意synchronized标记的同步对象属性,代码里定义了两种同步对象,分别静态的和非静态的):
public class SynchronizedTest {
private static Object objLog = new Object();
private Object objName = new Object();
// 实例方法的同步块
public void getName() {
synchronized(objName) { //同步对象可以换成this对象、非静态的对象、静态的对象
// synchronized(this) {
// synchronized(objLog) {
System.out.println(Thread.currentThread().getName() + ":-----------getName start---------");
try {
System.out.println(Thread.currentThread().getName() + ":-----------getName ing...---------");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":-----------getName end---------");
}
}
// 静态方法的同步块
public synchronized static void getLog() {
synchronized(objLog) { //同步对象不能换成this对象 或 非静态的对象
System.out.println(Thread.currentThread().getName() + ":---------》start getLog");
try {
System.out.println(Thread.currentThread().getName() + ":---------》getLog ing...");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":---------》getLog end");
}
}
// 开启多个线程进行测试
public static void main(String[] args) {
// 先测试同步实例方法(注释掉后面的测试同步静态方法)
Thread t1 = new Thread(() -> new SynchronizedTest().getName());
Thread t2 = new Thread(() -> new SynchronizedTest().getName());
Thread t3 = new Thread(() -> new SynchronizedTest().getName());
t1.start();
t2.start();
t3.start();
// 再测试同步静态方法(注释掉前面的测试同步实例方法)
// Thread thread1 = new Thread(() -> SynchronizedTest.getLog());
// Thread thread2 = new Thread(() -> SynchronizedTest.getLog());
// Thread thread3 = new Thread(() -> SynchronizedTest.getLog());
// Thread thread4 = new Thread(() -> SynchronizedTest.getLog());
// thread1.start();
// thread2.start();
// thread3.start();
// thread4.start();
}
}
测试结果如下:
- 静态方法的同步块测试:同步对象只能是静态对象,不能是this对象或非静态对象,测试结果与同步静态方法的测试结果一致;
- 实例方法的同步块测试:同步对象可以是this对象、非静态对象 或静态对象,当为this对象和非静态对象时测试结果与同步实例方法的测试结果一致,而当为静态对象时测试结果与同步静态方法的测试结果一致;
可以看出同步代码块的优点,可以针对任意代码块,且可任意指定上锁的对象,故灵活性较高,与同步方法比较,锁粒度更细。
3、使用synchronized注意的问题
- 线程同步方法是通过锁来实现,每个对象对应一个锁,这个锁与一个特定的对象关联,线程一旦获取了对象锁,其他访问该对象的线程就无法再访问该对象的同步方法;
- 编写线程安全的类,需要时刻注意对多个线程竞争访问资源的逻辑和安全做出正确的判断,对原子操作做出分析,并保证原子操作期间别的线程无法访问竞争资源;
- 当多个线程等待一个对象锁时,没有获取到锁的线程将发生阻塞;
- 死锁现象是线程间相互等待锁锁造成的,在实际中发生的概率非常的小,但也要注意避免。
volatile
关键字volatile有两大作用:一是保证变量的可见性;二是防止JVM对指令进行重排优化,保证有序性;
1、变量可见性
Java 内存模型( JMM)规定:所有的变量都存放在主内存中,而每个线程都有着自己的工作内存(CPU高速缓存)。在一个多线程的应用中,线程在操作非volatile变量时,出于性能考虑,每个线程可能会将变量从主内存拷贝到CPU缓存中。假如一台PC中有多个CPU,每个线程可能会在不同的CPU中运行。这意味着,每个线程都有可能会把变量拷贝到各自CPU的缓存中。而对于非volatile变量,JVM并不保证会从主内存中读取数据到CPU缓存,或者将CPU缓存中的数据写到主内存中的。
设想有多个线程访问共享对象里的某个非volatile变量(假如 int cnt = 0),如果线程A修改了这个变量的值,而线程A及其他线程在某个时刻去读取这个变量的值,因为非volatile变量并不能保证将最新值从CPU缓存写回到主内存中,这样就会造成主内存的变量值和CPU缓存的变量值不一致!
这就是变量可见性问题,一个线程修改非volatile变量的值对其他线程并不可见!因此,Java中volatile关键字则是设计用来解决变量可见性问题的:
- 将一个变量声明为volatile,可以保证变量写入时对其他线程的可见;
- 可以将对volatile变量的读写理解为一个触发刷新的操作,任何线程对变量的写操作都会立即刷新到主内存中,并且会强制让缓存了该变量的线程中的数据清空,必须从主内存重新读取最新数据。
volatile关键字解决了变量的可见性问题,保证了其他线程对变量修改可见,然而并不能保证线程的安全性。比如n++这种非原子性的操作,它的获取值,自增,赋值等一系列操作不能保证是同时完成的,这样在多线程环境里容易引发线程安全问题!
2、禁止指令重排
JVM和CPU出于性能考虑,是允许对程序中的指令进行重排的,只要保证重排后的指令语义一致即可。举个例子:
int a = 5; //1
int b = 10; //2
int c = a+b; //3
理想情况下,上面的代码步骤是按1,2,3进行的,但有可能JVM进行指令重排时,出现2,1,3的顺序,但不管怎样指令重排序,原子性操作的结果都是一样的(或者说重排后的指令语义是一致的)。那么对于非原子性操作呢,当进行指令重排优化时,在多线程环境中就会有问题,以单例模式中的懒汉式为例:
代码中的singleInstance = new SingleInstance();在编译代码时正常情况下可分为以下三步骤:
- 给对象分配内存空间(即在堆上开辟空间存储单例对象);//1
- 调用构造器初始化成员变量,形成实例;//2
- 将singleInstance对象引用指向内存地址;//3
JVM在编译时进行指令重排优化的话,可能会使得第2步和第3步是反过来的,这样程序会抛异常的。
解决这个问题使用的是双重检测锁 + volatile标记singleInstance变量:有一个线程,如果指令重排按照上面的1,3,2步骤进行的话,当进行到3时,其实singleInstance对象已经不为null了,此时另一个线程抢占执行了2,拿到 singleInstance对象直接去使用,这样会报错,因此关键问题是:一个线程对singleInstance对象还没完成写操作,另一个线程就执行了读操作,而singleInstance加上volatile变量就是防止这种现象,这就是内存屏障。
volatile关键字的一个作用是禁止指令重排,把singleInstance变量声明为volatile之后,对它的写操作就会有一个内存屏障,也就是说:volatile阻止的不是singleInstance = new SingleInstance();这句话内部[1-2-3]的指令重排,而是保证了一个线程在一个写操作([1-2-3])完成之前,其他线程不会调用读操作,避免出现对象不一致情况。
3、合理使用volatile
1、volatile关键字保证了变量的可见性,通过防止JVM进行指令重排优化,保证了变量的有序性,但不能保证变量的原子性(因此不能使用volatile进行如n++类似的非原子性操作,线程不安全),因此还需要借助一些手段保证其原子性:
- synchronized、ReentrantLock等锁;
- atomic原子类:如AtomicInteger 、AtomicLong 等;
2、volatile使用场景主要有:双重检测、状态标记量、读多写少等场景。
双重检测:上面提到的单例模式中双重检测锁的案例;(禁止指令重排、内存屏障)
状态标记量:例如优雅的实现线程终止的案例;(变量的可见性)
读多写少:与锁结合使用实现简易版的读写锁,锁保障写操作的原子性,volatile保证读操作的可见性。
transient
含义:瞬态的、短暂的,只能修饰变量,用来表示对象的成员变量不参与对象的序列化。
所谓的序列化,就是Java程序中的对象要想保存到内存或进行网络传输的话,需要将对象转化为字节序列进行传输,而序列化的终极目标是能够反序列化,将原来存在内存或网络上的字节序列转化为Java能够识别的对象。如果用transient关键字标识了对象中的成员变量,因不参与序列化过程,所以反序列化生成的新对象中不会有该成员变量的值,举例说明:
public class Test {
public static void main(String[] args) {
// 序列化之前赋予手机对象一些信息
PhonePO po = new PhonePO("华为P50-系列", 4, 6500);
// 序列化:写入对象输出流
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("E:\\test\\test.txt"))) {
oos.writeObject(po);
oos.flush();
} catch (IOException e) {
e.printStackTrace();
}
// po.setPhoneName("小米系列了解一下--");
// po.setPhoneMode(6);
// 反序列化:从对象输入流读取
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("E:\\test\\test.txt"))) {
PhonePO phone = (PhonePO) ois.readObject();
System.out.println("手机名称:---->" + phone.getPhoneName());
System.out.println("款型数量:---->" + phone.getPhoneMode());
System.out.println("手机平均价格:---->" + phone.getPhonePrice());
} catch (ClassNotFoundException | IOException e) {
e.printStackTrace();
}
}
}
// 自定义实体类
class PhonePO implements Serializable {
public static String phoneName; //手机名称
public int phoneMode; //款型数量
public transient long phonePrice; //新品手机平均价格
public PhonePO() {
}
public PhonePO(String phoneName, int phoneMode, long phonePrice) {
this.phoneName = phoneName;
this.phoneMode = phoneMode;
this.phonePrice = phonePrice;
}
// 省略getter/setter方法
}
PhonePO 是自定义的实体类,需要指定实现Serializable接口(Serializable是个空接口,标识当前对象是可序列化的),其中phoneName设置为static的、phonePrice设置为transient的,做以下测试:
测试1:在PhonePO 实体对象序列化之前,通过构造器给手机对象加入一些内容,使用对象输出流先序列化对象,再通过对象输入流反序列化,读取的内容如下:
手机名称:---->华为P50-系列
款型数量:---->4
手机平均价格:---->0
测试2:在反序列化之前修改一下PhonePO 对象的属性(即手机名称phoneName 和款型数量phoneMode ),读取内容如下:
手机名称:---->小米系列了解一下--
款型数量:---->4
手机平均价格:---->0
因此可以得出如下结论:
- 从手机平均价格phonePrice属性:transient修饰的成员变量不参与对象的序列化过程;
- 从款型数量phoneMode属性:普通成员变量参与对象的序列化过程,序列化保存的是对象状态,且反序列化时该对象状态不可被修改;
- 从手机名称phoneName属性:这里具有迷惑性,static修饰的成员变量好像参与了序列化,但反序列化之前变量的值却可以被修改!!
这一点值得思考和探索,这里我把序列化和反序列化的代码分开,也就是先注释掉反序列化代码,去执行序列化代码后test.txt生成了新的字节序列;然后注释掉序列化代码去执行反序列化代码,测试结果如下:
手机名称:---->null
款型数量:---->4
手机平均价格:---->0
有点神奇哈,phoneName属性不管修不修改,最终反序列化得到的都是null。仔细分析了一下,static修饰的成员变量保存的是类的状态,而不是序列化所保存的对象状态,也就是说static修饰的成员变量不参与序列化过程,但JVM没有退出时可能会保留着static修饰的成员变量的值,这就解释了刚开始测试为啥变量的值能被修改了,修改的是JVM运行时所保存的变量值,而非序列化中的值!!!!有点豁然开朗了,不错不错~
小结一下
至此Java关键字中的修饰符整理的差不多了,很多平时没有注意到的细节,在这次整理过程中也有了新的认识,也算是巩固了一下Java基础。接下来把其他的比较重要的关键字,如this、super、extends、implements等总结完毕后,关键字及涉及到的基础知识点也算巩固完成了。不积硅步无以至千里,点滴付出终将有所收获,共同进步 ~