JMM
JMM是什么呢?
JVM:java虚拟机
JMM: java内存模型。是一个概念,一个不存在的东西。
主内存和工作内存:我们线程在工作中都会有一个缓存区域。有些值都过来会缓存起来。然后下次使用的时候直接使用。
关于JMM的一些同步的约定:
- 线程解锁前,必须把共享变量立刻刷回主存。
- 线程加锁前,必须读取主存中的最新值到工作内存中。
-
加锁和解锁是同一把锁。
JMM八种操作
下面用代码证明下这个情况:
public class D1 {
public static void main(String[] args) throws Exception {
int num = 0;
new Thread(()->{
int i = 0;
while (num == 0) {
System.out.println(i++);
}
}).start();
TimeUnit.SECONDS.sleep(1);
System.out.println(num);
}
}
正常情况下因为过一秒钟后main线程把num赋值为2,所以while会停止的。但是事实上这个会是一个死循环,因为这个匿名线程从主线程读取过来的num就是0。而这个时候,volatile的可见性就可以起作用了。
volatile
- volatile能够保证可见性
- volatile不能保证原子性
看下图:
volatile不保证原子性
如上代码,正常的话应该num是10000*10-1.但是结果明显的比这个小,说明volatile是不保证原子性的。其实这个demo中,num++本身就不是原子性的。看似就一行代码。但是本质上是get。add。set三个操作。所以这里不应该用num++。我们应该用原子类数据类型操作,我们去手册上看下juc下的包:
juc中的包
这三个中,只剩下我框起来的咱们还没看了。点开就能发现,里面就是各种数值类型的原子类。但是原子类的底层不是我们熟悉的synchronized或者lock。它的底层是cas。这个我们后面介绍。
- volatile禁止指令重排
什么是指令重排?我们写的程序,计算不并不是按照写的那样一模一样执行的。
源代码->编译期优化的重排->指令并行可能会重排->内存系统也会重排->执行
而指令重排的时候,会考虑数据之间的依赖性。如下代码:
int x = 1; //1
int y = 2; //2
x = x+5; //3
y = x*x; //4
如上代码:我们以为是1,2,3,4这样的执行
但是其实 2,1,3,4或者1,3,2,4也都是可能的
但是,绝对不可能是 4,1,2,3(因为数据之间的依赖性,4中y是依赖x的)
上面的代码是不影响结果的,但是有些时候是会影响的,比如下面的图片:
指令重排导致的诡异情况
而volatile是可以避免指令重排的:
这里是内存屏障。CPU指令,作用是:
- 保证特定的操作执行顺序。
- 可以保证某些变量的内存可见性。(利用这些特性volatile实现了可见性)
总结一下:volatile可以保证可见性不可以保证原子性。并且禁止指令重排。
单例模式
单例模式其实也是细分好多类型的。下面一一列举:
饿汉式单例
这里其实java中经常说的一种模式。饿汉式,因为很饿,所以上来就创建了。用饥不择食理解?(我感觉这里和懒汉式对应着理解比较好记。因为比较懒所以用的时候才创建)。反正大概就是这个意思。
懒汉式单例
这个我上面其实就简单的说了,相比于饿汉式的上来就创建,懒汉式是用的时候创建,这个可以节约一些不必要的资源。
而说到懒汉式,不得不说代码相比于饿汉式要稍微麻烦一点。毕竟饿汉式上来就创建,非人为的,不存在创建多个,但是懒汉式常用的思维是判断是不是创建过了,没创建过才创建。这本身就是容易被多线程访问,如下代码:
/**
* 懒汉式单例
* @author 11511
*
*/
public class Lazy {
public static Lazy lazy;
private Lazy(){
}
public Lazy getInstance() {
if(lazy == null) {
lazy = new Lazy();
}
return lazy;
}
}
其实我们在构造器中添加个打印语句,同时多线程跑这个getInstance方法很容易就能看出多线程时这个方法是不安全的。
其实最直观的方法是在getInstance方法上加锁。但是这个锁的粒度太大了。非常影响性能。而且以后想要拿这个单例也要一个个排队拿。其实仔细分析就能明白这个锁加在方法上有多不合适了。所以这里最好是把锁加在创建单例对象的语句上面。并且如果加在new语句外面(这样起码创建完成后不会存在锁了)其实也会有问题,比如在外层==null中检测是null,所以进入到等锁的时候,但是这时候别的锁里把这个单例创建出来了。这里其实还是创建了两次的。所以这里有一个经典的模式:双重检测锁模式。
如下代码截图:
双重检测锁模式
但是其实这样还是有问题的。我们上面说了指令重排。比如说初始化对象分三步:
- 分配空间
- 初始化对象
- 对象指向空间
正常我们希望从上往下执行。但是!如果指令重排了,执行的1.3.2。有可能先对象指向空间,然后初始化对象。
这个时候如果别的线程进入这个方法,因为执行完3觉得初始化完成了,使用了这个单例对象。本质上还没有初始化对象。所以这个时候这个对象是不能使用的,所以会出现各种诡异的问题!所以懒汉式单例还是要在对象上加个volatile来避免指令重排的!(注意:这里的volatile和什么可见性原子性一毛钱关系都莫得,单纯为了避免指令重排!)
静态内部类
这个方式其实也挺简单的,属于类加载就创建了的。我直接附上代码:
/**
* 静态内部类
* @author 11511
*
*/
public class D2 {
private D2() {
}
public static D2 getInstance() {
return InnerClass.D2;
}
public static class InnerClass{
private static final D2 D2 = new D2();
}
}
反射
但是,上面说了那么那么多,其实都没啥用。因为java中有个非常非常屌的技术:反射。
只要有反射,任何的私有关键字都是纸老虎。
反射的知识其实挺多的,我这里就用到什么简单的说两句。
先去官方手册上把要用到的方法说一说:
获取到私有的这个构造器以后,破坏它:
破坏用的方法是下面图中说明的。
破坏private的修饰作用
父子关系
Contructor创建实例方法
其实知道了这几个方法,我们就可以无限调用一个私有的构造器方法啦,下面上代码:
/**
* 懒汉式单例
* @author 11511
*
*/
public class Lazy {
public static Lazy lazy;
private Lazy(){
System.out.println("<<<<<创建单例对象");
}
public static Lazy getInstance() {
if(lazy == null) {
synchronized (Lazy.class) {//类锁
if(lazy == null) {
lazy = new Lazy();
}
}
}
return lazy;
}
public static void main(String[] args) throws Exception{
Lazy lazy = Lazy.getInstance();
Constructor<Lazy> constructor = Lazy.class.getDeclaredConstructor();
constructor.setAccessible(true);
Lazy lazy2 = constructor.newInstance();
System.out.println(Lazy.getInstance().equals(lazy));
System.out.println(lazy.equals(lazy2));
}
}
反射破坏了双重检测锁模式的单例模式
其实这个问题是可以解决的。因为走了构造器,所以我们可以设置构造器只能调用一次,否则报错。
设置构造器只能走一起,否则报错
其实就是在构造器里添加了点东西。这样刚刚那样反射获取对象会报错:
根据i的值判断构造器只能走一次
但是!你以为到这里就完事了么?不不不,毕竟这个i也是类种的一个属性。我就不能获取到属性,手动重置它么?如下代码:
/**
* 懒汉式单例
* @author 11511
*
*/
public class Lazy {
public static Lazy lazy;
public static int i;
private Lazy(){
synchronized (Lazy.class) {
if(i == 0) {
i++;
System.out.println("<<<<<创建单例对象");
}else {
throw new RuntimeException("不要试图破坏单例模式!");
}
}
}
public static Lazy getInstance() {
if(lazy == null) {
synchronized (Lazy.class) {//类锁
if(lazy == null) {
lazy = new Lazy();
}
}
}
return lazy;
}
public static void main(String[] args) throws Exception{
//先看看这个类有什么属性
Field[] fields = Lazy.class.getDeclaredFields();
for(Field f:fields) {
System.out.println(f);
}
//如果真的有心的话,可以把这些属性的值都获取到。下面用i做例子
Field field = Lazy.class.getDeclaredField("i");
System.out.println(field.getInt(lazy));
Lazy lazy = Lazy.getInstance();
//正常途径创建完单例后,把这些值改成没创建单例之前的
field.set(lazy, 0);
//再走私有构造器创建一个
Constructor<Lazy> constructor = Lazy.class.getDeclaredConstructor();
constructor.setAccessible(true);
Lazy lazy2 = constructor.newInstance();
System.out.println(lazy == lazy2);
}
}
在构造器上加限制也被破解了
反正就是道高一尺魔高一丈,现在在构造器上加限制也没啥用了。那到底该怎么办呢?从newInstance源码开始找解决办法吧。点进去就会发现:
敲黑板!!兄弟们看到没,红色框起来的这么显眼的一段话:不能反射创建枚举对象!!!
所以说,想要是单例的,枚举是最安全的(枚举类型是jdk1.5出来的)。所以,到底为什么枚举就安全呢?这个Enum应该应该每个人都知道,我们一步一步测试原因。
首先创建枚举类,常规测试看会有什么结果
/**
* 创建一个枚举类
* @author 11511
*
*/
public enum EnumSingle {
INSTANCE;
public EnumSingle getInstance() {
return INSTANCE;
}
}
然后用常规的方法测试看会怎么样?
其实看报错,我觉得是可以理解的,毕竟我们的枚举类根本没写构造器,所以获取无参构造器不存在这个方法没啥问题。可是枚举作为一个类不用构造器的么?这是不可能的。所以我们还得往下找。这个枚举类构造器到底是什么玩意?这里编译器中是进不去Enum里瞅瞅了,所以手册上找吧。Enum是java.lang包下面的,我们去瞅瞅:
Enum类
这个类,有点东西啊,我们在手册中能看到Enum是有构造器的。而且根据刚刚的文字描述,我们可以大胆猜测一下,指不定我们这个枚举儿子也是这个构造器,只不过我们面上没看到而已。去试试代码:
事实证明猜对了
其实这个猜测大家也可以用事实做证据。反编译一下字节码啥的。
反正至此,我们已经能创建出无法被破坏的单例啦, 所以你学会了如何彻底玩转单例模式么?
本篇笔记就记到这里,如果稍微帮到你了记得点个喜欢点个关注,也祝大家工作顺顺利利,周末愉快哟!
网友评论