《深入理解Java虚拟机》读书笔记

此文为我在学习《深入理解Java虚拟机:JVM高级特性与最佳实践》时所做的笔记,把我认为是重点、面试时可能会被问到的知识点给记录了下来,自认为是《深入理解Java虚拟机》这本书的精华。关于这些知识点,有的在我理解以后并没有进行展开叙述。可当做是一个JVM知识点提纲来看,有不懂的地方大家可针对知识点展开学习。

Java内存区域与内存溢出异常
  • Java内存区域分为Java堆,虚拟机栈,方法区,本地方法栈,程序计数器;其中,程序计数器、虚拟机栈和本地方法栈属于线程私有,Java堆和方法区线程共享;
  • 方法区用于存储虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等数据“永久代”),这一区域内存回收的目标主要是针对常量池的回收和对类型的卸载;
  • 运行时常量池是方法区的一部分。Class文件中除了有类的版本信息、字段、方法、接口等描述信息外,还有一项是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池进行存放;运行时常量时相对于Class文件常量池具备动态性,运行期间也可能将新的常量放入池中(String.intern()方法,检测常量池中是否有特定字符串,如果有,返回常量引用,如果没有,加入常量池并返回该常量引用);
  • 在堆中分配内存时,有“指针碰撞”和“空闲列表”两种分配方式,具体哪种,需要看垃圾收集器具体采用何种GC算法;
  • 对象的内存有三部分构成:对象头、示例数据和对齐填充;
  • 对象头有两部分构成:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有锁、偏向线程ID、偏向时间戳等;对象头的另一部分是类型指针、即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的示例。另外,如果这个对象是一个数组,对象头中海油一块用于记录数组长度的数据。
  • 对象的示例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。
  • 对象的访问定位,通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针两种方式。如果使用句柄访问的话,那么Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象示例数据与类型数据各自的地址信息;如果使用直接指针访问,那么Java堆对象的布局就必须考虑如果安置访问类型的相关信息,而reference中存储的直接就是对象地址。使用句柄的最大好处是在对象被移动(垃圾收集时)时,只会改变句柄中的示例数据指针,而不用修改reference本身;使用直接指针方式最大的好处在于速度更快,省去了一次指针定位的时间开销;HotSpot使用的是直接指针方式进行对象定位的。

垃圾收集器与内存分配策略
  • 程序计数器、虚拟机栈和本地方法栈,随线程生,随线程灭。虚拟机栈、本地方法栈中的各栈帧需要分配多大内存,在编译器就可确定下来,故这部分内存回收具有可确定性;
  • 确定对象是否存活:引用计数法(有循环引用问题)和可达性分析。可达性分析算法的基本思路: 通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。
  • 在Java中,可作为GC Roots的对象包括: 虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象。
  • 如果reference类型的数据中存储的数值代表的是另一块内存的起始地址,称这块内存代表一个引用。
  • 引用分为:强引用(只要引用关系还在,就永远不会回收掉被引用的对象)、软引用(用来描述一些还有用但并非必须的对象,内存溢出之前会把他们进行二次GC)、弱引用(也是用来描述非必须对象,强度比软引用还弱一些、被弱引用关联的对象只能生存到下一次垃圾收集发生之前)、虚引用(引用关系中最弱,不会对对象的生存时间构成影响,也无法通过虚引用来取得一个对象的示例,为一个对象设置虚引用的唯一目的就是能在这个对象被垃圾收集器回收之前收到一个系统通知)。
  • 对象真正的死亡,需要经过两次标记:第一次经过可达性分析以后发现没有与GC Roots相连接的引用链,将会进行第一次标记并且进行一次筛选,依据是是否覆写了finilize()方法或该方法是否已经执行过一次,如果发现需要执行finilize()方法,便会将对象放置到F-Queue队列中,并在稍后由虚拟机自动创建的、低优先级的Finalizer线程去执行它。虚拟机触发这个方法,并不能保证这个方法会执行完,如finilize()方法中出现死循环的时候虚拟机不会等待这个方法做完。如果在执行finilize()方法的时候,该对象重新与引用链上的任何一个对象建立关联,在这第二次标记的时候会被移除出“即将回收”的集合;
  • finilize()方法只会被系统执行一次,如果对象面临下一次回收,它的finilize()方法不会再次执行。
  • 回收方法区:废弃常量和无用类。判断无用类需要满足三点:该类的所有示例都已经被回收,也就是Java堆中不存在该类的任何引用;加载该类的ClassLoader已经被回收;该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法;
  • 垃圾收集算法:标记-清理,缺点:效率低+产生大量不连续空间,导致大对象无法再次分配;复制算法,缺点,存在空间浪费,不能百分之百的利用空间去给对象分配内存;标记-整理,标记完以后让所有存活的对象都向一端进行移动;分代收集:年轻代,朝生夕死,采用复制算法,老年代,存活时间比较久,采用标记-清理或者标记-整理算法;
  • 大对象直接进入老年代。大对象是指:需要大量连续空间的Java对象,比如很长的字符串和数组;
  • 长期存活的对象将进入老年代。在GC过程中,年轻代中达到年龄阈值(默认为15)的对象会被移入老年代;
  • 动态对象年龄判定,虚拟机并不是永远要求对象的年龄必须达到了MaxTrenuringThreshold才能晋升老年代,如果在Sorvivor空间中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就可以直接进入老年代,无需等到达到MaxTenuringThreshold中要求的年龄;

类文件结构
  • 实现语言无关性的基础仍然是虚拟机和字节码存储格式。
  • Class文件中的常量池主要存放两大类常量:字面量和符号引用。
  • 字面量比较接近Java语言层面的常量的概念,如文本字符串、声明为final的常量值等。
  • 符号引用包含了三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
  • 动态链接,在虚拟机加载Class文件的时候才进行“链接”操作,所以Class文件中不会保存各个方法、字段的最终的内存布局信息。这些字段、方法的符号引用不经过运行期转换的话,无法得到真正的内存入口地址,也就无法直接被虚拟机使用。
  • Class文件中的方法、字段都是要引用CONSTANT_Uft8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。
  • 分析Class文件字节码的工具:javap
  • 字段表集合中不会列出从超类或者父接口中继承而来的字段,但是有可能列出原本Java代码中不存在的字段,比如在内部类中为了保持对外部类的访问性,会西东添加指向外部类示例的字段。
  • Java语言中的字段是无法重载的,两个字段的数据类型、修饰符不管是不是相同,都必须使用不一样的名称。但对于字节码来说,如果两个字段的描述符不同,那字段重名就是合法的;
  • 与字段表集合相对应,如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息。
  • 在Java语言中,要重载一个方法,除了要与原来的方法具有相同的简单名称之外,还必须要有不同的方法特征签名。特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合;
  • 在Java代码中的方法的特征签名只包括了方法名称、参数顺序和参数类型;但在字节码中方法的的特征签名还包括方法返回值和受查异常表。但是在Java代码中,即使两个方法特征签名完全一样,但返回值不一样,这两个方法也是可以合理的共存于一个Class文件中的。
  • 在任何示例方法里面,都可以通过“this”关键字访问到此方法所属的对象,所以在示例方法的局部变量表中至少会存在一个指向当前对象示例的局部变量。
  • 编译器使用异常表而不是简单的跳转命令来实现Java异常及finally处理机制。
  • 对于非static类型的变量(示例变量)的赋值,是在示例构造器<init>方法中进行的;而对于类变量,则有两种方式可以选择:在类构造器<clinit>方法中或者使用ConstantValue属性。如果同时使用final和static来修饰一个变量,并且这个变量的数据类型为基本类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型及字符串,则会选择<clinit>方法中进行初始化。之所以要求必须是基本类型或者String类型,是因为ConstantValue的值只是常量池的索引,而常量池存储的是字面量和符号引用,符号引用需要在类加载的时候才能转化为直接内存地址,所以在生成Class阶段,即使ConstantValue属性想支持别的类型也无能为力;
  • Java泛型的实现采用的是擦除法,在字节码中,泛型信息编译之后会被通通擦除掉。Signature属性就是为了弥补这个缺陷而增设的,Java反射能够获取泛型类型,最终的数据来源,也是根据这个属性。
  • Java虚拟机的指令由一个字节长度的、代表这某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operands)而构成。Java虚拟机是面向操作数栈而不是寄存器架构,所以大多数的指令都不包含操作数,只有一个操作码。
  • 处理浮点数运算时,不会抛出任何运行时异常,如果一个操作产生溢出,将会使用有符号的无穷大来表示;如果一个操作结果没有明确的数学定义的话,将会用NaN的值来表示,所有使用NaN的值作为才作数的算术操作,结果都会返回NaN。
  • 虚拟机的实现主要有两种方式:
    • 将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集;
    • 将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(即JIT代码生成技术);

虚拟机类加载机制
  • 虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制;
  • 类从被加载到虚拟机内中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备和解析三个阶段统称为连接。
  • 虚拟机规定了5种情况下必须立即对类进行“初始化”:
    • 遇到new、getstatic、putstatic或invokestatic这4条指令时,如果类没有进行过初始化,则需要先触发其初始化操作。常见的常见是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果写在常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化;
    • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
    • 当虚拟机启动的时候,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类;
    • 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle示例最后的解析结果REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
  • 通过子类引用父类的静态字段,不会导致子类初始化。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
  • 通过数组定义来引用类,不会触发此类的初始化。
    public class Test{
        public static void mian(String[] args){
            Clazz[] array = new Clazz[10]; // 此时不会触发Clazz类的初始化
         }
    }
  • 字面常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
    public class ConstClass{
        static {
            System.out.println("ConstClass init!");
        }
        
        public static final String HELLOWORLD = " hellow world ";
    }
    
        public class Test{
        public static void mian(String[] args){
            System.out.println(ConstClass.HELLOWORLD); // 此时不会触发ConstClass类的初始化,不会输出"ConstClass init!"
         }
    }
  • 接口初始化时机与类不同的在于第3种:当一个类在初始化时,要求其父类全部都已经经过初始化了,但是一个接口在初始化时,并不要求其父接口全部都已经完成了初始化,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
  • 在加载阶段,虚拟机需要完成以下3件事情:
    • 通过一个类的全限定名来获取定义此类的二进制字节流;
    • 将整个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
    • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法去这个类的各种数据的访问入口;
  • 对于数组类而言,本身不通过类加载器创建,它是有java的虚拟机创建的。
  • 加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需格式存储在方法区之中,然后在方法区中实例化一个java.lang.Class类的对象,它将作为程序访问方法区中的这些类型数据的外部接口。
  • 验证阶段完成4个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证;
  • 文件格式验证,验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理;
  • 元数据验证,对类的元数据信息进行语义分析,保证其描述的信息符合Java语言规范的要求;
  • 字节码验证,对类的方法体进行校验分析,保证被校验的类的方法在运行时不会做出危害虚拟机安全的事件。
  • 符号引用验证,该验证过程发生在讲符号引用转化为直接引用的时候-也就是解释阶段,该阶段保证解析动作能够正常执行;
  • 准备阶段是正式为类变量分配内幕才能并设置类变量初始化的阶段,这些变量所使用的内存都将在方法区中进行分配。这个时候分配的变量仅仅包括类变量(被static修饰的变量),不包括示例变量。
public static int value = 123;//在准备阶段以后,初始值是0,而不是123;而赋值为123的动作发生在初始化阶段
  • 如果类字段属相表中存在ConstantValue属性,将在准备阶段变量value就会被初始化为ConstantValue属性所指定的值。
public static final int value = 123; //编译时会为该字段生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue属性将value初始化为123;
  • 解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
  • 符号引用,以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
  • 直接引用,直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
  • 在需要操作符号引用的字节码在执行之前,必须先对他们所使用的符号引用进行解析。
  • 虚拟机对同一个符号引用解析的结果会进行缓存,在运行时常量池中记录直接引用,并把常量标识为已解析状态,从而避免解析动作重复进行。
  • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
  • 类或接口符号引用的解析,如果该类从未被解析过:如果对象不是数组,则会把该类的全限定名交给调用类的类加载器进行加载,此时可能会触发元数据验证、字节码验证以及触发其他相关的类加载动作;如果是数组,并且数组的元素类型是对象,则会把元素类型按照上述进行加载解析。解析完后会验证访问权限。
  • 字段的符号引用的解析,首先会解析字段表内class_index项中索引的CONSTANT_Class_info所代表的类进行符号引用解析。解析完字段所代表的类以后,按照先类本身、再按照继承关系从下往上搜索接口、最后按照继承关系从下往上递归搜索其父类,在此过程中,如果搜索到简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。如果查找失败,抛出java.lang.NoSuchFieldError异常。最后还会对权限进行验证。
  • 类方法符号引用的解析,与字段解析一样,首先会解析字段表内class_index项中索引的CONSTANT_Class_info所代表的类进行符号引用解析。在解析的收,如果发现class_index中索引的类是个接口,则会抛出java.lang.IncompatibleClassChangeError异常。如果解析出了方法所述的类或接口C,会按照先C类本身、再类C的父类中递归的顺序进行查找,如果找到了简单名称和描述符都与目标方法相匹配的方法,则返回这个方法的直接引用,查找结束;否则在类C的接口列表及它们的父接口之中递归查找,如果简单名称和描述符都与目标相匹配的方法,则说明这个方法在类C中没有实现,抛出java.lang.AbstractMethodError异常。如果上述过程都没有找到,则抛出java.lang.NoSuchMethodError。同样,如果解析出了方法的直接引用,同样要检验权限。
  • 接口方法符号引用的解析,首先解析出接口方法表class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,会先校验解析出的接口C,如果发现是类而不是接口,会抛出java.lang.IncompatibleClassChangeError异常。然后会按照先接口C、再递归查找C的父接口(直到Object类),如果找到简单名和描述符都与目标相同的方法,则会直接返回该方法的直接引用,否则,会抛出java.lang.NoSuchMethod异常。由于接口中所有的方法默认都是public的,所以不存在访问权限问题,所以不会抛出java.lang.IllegalAccessError异常。
  • 初始化阶段, 是执行类构造器<clinit>方法的过程:
    • 自动给类变量(static修饰的变量)和静态语句块(static {}块),顺序按照语句在源文件出现的顺序。静态语句块只能访问到定义在静态语句块之前的变量,但却可以给定义在静态语句块之后的静态变量进行赋值,但却不能访问。
    • <clinit>方法与类的构造器(示例构造器<init>)不同,它不需要显式的调用父类构造器,虚拟机会保证子类的<clinit>方法执行前,父类的<clinit>方法已经执行,所以虚拟机中第一个被执行的类构造器<clinit>方法的类肯定是java.lang.Object。
    • 由于父类的<clinit>方法要先执行,也意味着父类的静态语句块和变量的赋值操作要优先于子类的静态语句块先执行。
    • <clinit>方法并不是必须的,如果没有静态语句块也没有对变量的赋值操作,编译器可以不生成该方法。
    • 接口的<clinit>方法对于接口而言(接口不可以有静态语句块,但可以定义变量,有变量的初始化的赋值操作),不像类那样,执行接口的<clinit>方法,并不需要先执行父接口的<clinit>方法。只有当父类中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时,也不会执行接口的<clinit>方法。
    • 一个类的<clinit>方法在多线程环境下,会被正确的加锁、同步,多个线程同时执行一个类的构造器方法,则只有一个线程会执行,其他的线程会被阻塞等待。
  • 类加载器的作用:通过一个类的全限定名来获取描述此类的二进制字节流。
  • 每一个类加载器,都拥有一个独立的名称空间,所以比较两个类是否“相等”,只有两个类是由同一个类加载器加载的前提下才有意义。两个类相等,包括类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回接口,也包括instanceof关键字做对象所属关系的判定等情况。
  • 类加载器,一般分为三种:启动类加载器(可以理解为加载java系统等被虚拟机识别的类,用户不能直接使用)、扩展类加载器(加载用户导入的jar等)和应用程序加载器(负责加载用户路径ClassPath上指定的类库)。
  • 类加载器的模型是双亲委派模型,除了顶层的启动类加载器,其余的类加载器都要有自己的父类加载器,但是他们之间的关系一般不会是继承,而是使用组合的关系来复用父加载器的代码。
  • 双亲委派模型,如果一个类加载器接收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给自己的父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

虚拟机字节码执行引擎
  • 在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备。
  • 运行时栈帧,是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态廉洁和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
  • 在编译程序代码的时候,每一个栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定,写在方法的Code属性之中。
  • 在活动的线程中,只有位于虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有的字节码指令都只针对当前栈帧进行操作。
  • 局部变量表是一组变量值的存储空间,用于存放方法参数和方法内定义的局部变量。编译时,局部变量表的最大空间写在了Code属性的max_locals中,单位是Slot。
  • 虚拟机的实现要求通过一个引用变量要能做到两点:
    • 从此引用中直接或间接的查找到对象在Java堆中的数据存放的起始地址;
    • 次引用中直接或间接查找到对象所属的数据类型在方法区中存储的类型信息;
  • 由于局部变量表在虚拟机栈帧中,是属于线程私有数据,所以对于long和double来说,连续读写两个Slot,不会引起数据安全问题。
  • 虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大的Slot数量。
  • 在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的。
  • 局部变量表可以复用,有时会造成某些时候对象不能被回收的情况出现。而一个对象能否被回收的根本原因是:局部变量表的Slot是否还存有关于该对象的引用。
  • 类变量有两次附初始值的过程,一次是在准备阶段,赋予系统的初始值;另外一次是在初始化阶段,赋予程序员定义的初始值。但是一个局部变量表定义了但没有赋予初始值是不能使用的。
  • 操作数栈的最大深度在编译的时候写在了Code属性的max_stacks数据项中。每一个元素可以使任意的java数据类型,在方法执行的时候,操作数栈的最大深度不会超过max_stacks数据项中设定的最大值。
  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。
  • 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配。
  • Java虚拟机的解释执行引擎称之为“基于栈的执行引擎”,这个栈指的就是操作数栈。
  • 每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中执行方法的符号引用作为参数。这些符号引用一部分会在类的加载阶段或者第一次使用的时候就转化为直接引用,这个转化成为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分成为动态连接。
  • 当一个方法开始执行时,有两种方法可以退出这个方法:遇到任意一个方法返回的字节码指令,称之为正常完成出口;另一种是遇到了异常(虚拟机内部的异常或者athrow字节码异常指令),称之为异常完成出口。
  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后边的一条指令等。
  • 方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪个方法),暂时还不涉及方法内部的具体运行过程。
  • 一切方法调用在Class文件里边存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
  • 所有的方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会有一部分符号引用转化为直接引用,即静态解析,它成立的前提是:方法在真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可变的。
  • 在java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括“静态方法”和“私有方法”两大类。
  • java提供了5条方法调用字节码指令:
    • invokestatic:调用静态方法;
    • invokespecial:调用示例构造器<init>方法、私有方法和父类方法。
    • invokevirtual:调用所有的虚方法。
    • invovkeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
    • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
  • 只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实力构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法称之为非虚方法。
  • java中的非虚方法除了使用invokestatic\invokespecial调用方法之外还有一种,就是被final修饰的方法。虽然fianl方法是使用invokevirtual指令来调用的,但它无法被覆盖,也无需对方法接收者进行多态选择。
  • 解析调用是一个静态的过程,在编译器期间就可以完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。
  • 静态类型(外观类型)与实际类型,静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译器是可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
 //Human称为变量的静态类型或外观类型
 //Man称为变量的实际类型
 Human man = new Man();

 //实际类型的变化
  Human man = new Man();
  man = new Woman();
  //静态类型变化
  sr.sayHello((Man)man);
  sr.sayHello((Woman)man);
  • 在有重载的情况下的方法调用,使用哪个重载版本,完全取决于传入参数的数量和数据类型。虚拟机(准确说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判断依据的。并且静态类型是编译器可知的,因此,在编译阶段,Javac编译器就会根据参数的静态类型决定使用哪个重载版本。
  • 所有依赖静态类型来定位方法之行版本的分派动作称之为静态分派。静态分派的典型应用是重载。
  • invokevirtual指令运行时解析过程大致分为以下几个步骤:
    • 找到操作数栈顶的第一个元素所指向对象的实际类型,记作C。
    • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,通过则返回这个方法的直接引用,查找过程结束。如果不通过,返回非法访问异常。
    • 否则,按照继承关系从下往上依次对C的各个父类进行上述步骤的搜索和验证。
    • 如果始终没有找到合适的方法,抛出java.lang.AbstractMethodError异常。
  • 把在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。典型的应用是方法重写。重写的本质是:invokevirtual指令把常量池中类方法符号引用解析到了不同的直接引用上。
  • 方法的接收者和方法的参数统称为方法的宗量。
  • Java语言的动态分派属于单分派类型。所以在Java发展到1.8之前,属于静态多分派,动态单分派的语言。
  • 由于动态分派是非常频繁的动作,基于性能的考虑,最常用的“稳定优化”手段就是在类的方法区中建立一个虚方法表(与此对应的,也会有接口方法表),使用虚方法表索引来代替元数据查找以提高性能。
  • 虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被复写,那子类的虚方法表里边的地址入口和父类相同方法的地址是一致的,都指向父类的实现入口。如果子类复写了这个方法,子类方法表的地址会替换为指向子类实现版本的入口地址。
  • 为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需方法的入口地址。
  • 方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
  • MethodHanle的使用方法和效果与Reflection的区别有如下几点:
    • 从本质上讲,Reflection和MethodHandle机制都在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。
    • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象包含的信息多。前者是方法在Java一端的全面映射,包含了方法签名、描述符以及方法属性表中各种属性的Java端的表现方式,还包含执行权限等运行期信息。后者仅仅包含与执行方法相关的信息。Reflection是重量级的,MethodHandle是轻量级的。
    • 由于MethodHandle是对字节码的方法指令的模拟,所以理论上虚拟机在这方面做的各种优化在MethodHandle上是支持的,但是java反射则不行。
  • 现代的高级语言,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树。
  • 基于栈的指令集主要优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件的约束。还有其他的优点如diamante相对更加紧凑(字节码中的每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配问题,所需空间都是在栈上操作)等。主要的缺点就是执行起来相对会稍慢一些。

早期(编译期)优化
  • Java语言的“编译期”其实是一段“不确定”的操作过程,它可能包含几个个过程:
    • 可能是指一个前端编译器把.jva文件转变成.class文件的过程;
    • 也可能是指虚拟机的后端运行期编译器(JIT编译器)把字节码转变成机器码的过程;
    • 还可能指使用静态提前编译器(AOT编译器)直接把*.java文件编译成本地机器代码的过程。
  • 虚拟机运行时不支持一些语法,他们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。Java中最常用的语法糖主要有:泛型、变长参数、自动装箱/拆箱等、内部类、枚举类、断言语句、枚举、字符串的switch;
  • 泛型是JDK1.5的一项新增特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。Java泛型只存在于源码中,编译后的字节码文件中,已经替换为原生类型了,并在相应的地方插入了强制转型代码,所以这种采用擦除法实现的泛型是一种伪泛型,只是一个语法糖而已。
  • 仅仅是泛型的类型参数不同的方法是无法重载的,因为编译成字节码的时候把类型参数进行擦除导致方法签名一样。但是有不同的返回值缺可以正常编译,因为:在Java代码中的方法的特征签名只包括了方法名称、参数顺序和参数类型;但在字节码中方法的的特征签名还包括方法返回值和受查异常表。但是在Java代码中,即使两个方法特征签名完全一样,但返回值不一样,这两个方法也是可以合理的共存于一个Class文件中的。
  • Signature属性存储一个方法在字节码层面的特正确签名,这个属性中板寸的参数类型并不是原生类型,而是包括了参数化类型信息。
  • 自动装箱、拆箱,在编译之后被转化成了对应的包装和还原方法,如Integer.valueOf()与Integer.intValue()方法;
  • 遍历循环,则把代码还原升了迭代器的实现。
  • 变长参数,它在调用的时候变成了一个数组类型的参数。
  • 要实现C++中的条件编译,在Java中只要使用条件为常量的if语句即可,编译器会把条件不成立的代码块擦除掉。
if(true) {
    //此处会被编译,生成字节码
} else {
    //此处不会被编译,不会生成对应的字节码
}
  • 如果想在编译器做一些事情,可以考虑采用注解处理器来实现,需要继承抽象类javax.annotation.processing.AbstractProcessor。

晚期(运行期)优化
  • Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即使编译器(Just In Time Compiler)。
  • 解释器与编译器并存的的架构的优点:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译时间,立即执行。当程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的效率。当程序运行环境中内存资源的限制较大,可以使用解释器执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个逃生门。
  • 在运行过沉重,会被即使编译器编译的“热点代码”有两类,即:
    • 被多次调用的方法
    • 被多次实行的循环体
  • 判断一段代码是不是热点代码,是不是需要触发即使编译,这样的行为被称为“热点探测”,目前主要的热点探测判定方式有两种:
    • 基于采样的热点探测:虚拟机周期性的检查各线程中虚拟机栈的栈顶,如果发现某个方法经常出现在栈顶,则判定为“热点方法”;
    • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为是“热点方法”。
  • 被即时编译的方法和循环体(循环体多次调用也会导致整个方法被进行编译)发生在方法执行的过程中,因此形象的称之为栈上替换(On Stack Replacement)。
  • 在HotSpot虚拟机中使用的是基于计数器的热点探测方法,每个方法有两类计数器:方法调用计数器和回边计数器。
    • 方法调用计数器,统计方法被调用的次数(方法调用计数器热度的衰减)。
    • 回边计数器,统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。
  • 在默认情况下,无论是方法调用产生的即使编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方尺继续执行,而编译动作则在后台的编译线程中进行。
  • 方法内联优化:把目标方法代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。
  • 方法内联的目的:去除方法调用成本(如建立栈帧等)和为其他优化建立良好基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段。
  • 公共子表达式消除:如果一个表达式E已经计算过了,并且从先前计算到现在E中所有变量的值都没有发生变化,那么E这次出现就成为了公共子表达式,对于E,直接用前面计算过的结果就可以了。
  • 数组边界检查消除:除了尽可能把运行期检查提到编译期完成完成的思路之外,还有一种避免思路,隐式异常处理。
  • 编译期在进行内联时,如果是非虚方法,那么直接进行内联就可以了。如果遇到虚方法,则会向“类型继承关系分析”查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本,也可以进行内联。不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这种内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。如果通过“类型继承关系分析(CHA)”查询有多个版本的目标方法可供选择,会使用内联缓存来完成内联。
  • 逃逸分析:逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还可能被外部线程访问到,比如赋值给类变量或者可以在其他线程中访问的实例变量,称为线程逃逸。
  • 如果能证明一个对象不会逃逸到方法或者线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可以对其进行如下的优化:
    • 栈上分配:Java堆中的对象对于各个线程都是共享和可见的,依赖于垃圾回收。如果一个对象不会逃逸到方法之外,那让这个对象在栈上分配内存,这个对象就会随着方法的结束而自动销毁了;
    • 同步消除:线程同步本身相对比较耗时,如果逃逸分析确定一个变量不会逃逸出线程,无法被其他线程访问,则可以进行同步消除;
    • 标量替换:标量是指一个数据已经无法再被分解成更小的数据来表示了,如原始数据类型;相对的是聚合量。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步优化手段创建条件;

Java 内存模型与线程
  • Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了示例字段、静态字段和构成数组对象的元素,不包括局部变量和方法参数,他们是线程私有。
  • Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之前也无法直接访问对方工作内存中的变量,线程间的变量值的传递均需要通过主内存来完成。
  • Java内存模型中定义了以下8种操作来完成工作内存与主内存之间数据读取和同步的操作:
    • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
    • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放出来后的变量才可以被其他线程锁定;
    • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
    • load(载入):作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中;
    • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
    • store(存储):作用于工作内存的变量,它把工作内存的一个变量的值传送到主内存中,以便以后的write操作使用。
    • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存变量中。
  • 如果要把一个变量从主内存复制到工作内存,就要顺序的执行read和load操作,如果要将一个变量从工作内存同步回主内存,就要顺序的执行store和write操作。
  • volatile是Java虚拟机提供的最轻量级的同步机制,当一个变量定义为volatile之后,它将具备两种特性:
    • 第一是保证次变量对所有线程的可见性(一个线程修改了变量的值,其他线程可以立马得知,是由于各个线程在使用该变量之前,都会对其工作内存中的该变量进行刷新)。由于解释执行或者编译执行时,只有用到变量的字节码会触发刷新变量的操作,所以会导致不一致的问题存在。在不符合以下两条规则的运算场景中,仍然要通过加锁来保证原子性:
      • 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值;
      • 变量不需要与其他的状态变量共同参与不变约束。
    • 使用volati变量的第二个语义是禁止指令重排序优化。
  • Java内存模型的三大特性:
    • 原子性:基本数据类型的访问读写具备原子性,但是long和double有非原子性协定。synchronized关键字可以保证代码块之间的原子性;
    • 可见性:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。除了volatile保证多线程变量的可见性,synchronized和final两个关键字也能实现可见性。被final修饰的字段在构造器中一但初始化完成,并且构造器没有把“this”的引用传递出去,那再其他线程中就能看见final字段的值(如果发生了this引用逃逸,可能在初始化的时候有其他线程也在初始化这个变量)。
    • 有序性:如果在本线程内观察,所有操作都是有序的:如果在一个线程中观察另一个线程,所有的操作都是无序的。Java余元提供了volatile和synchronized两个关键字来保证线程之间的有序性。
  • 线程的主要实现方式:
    • 使用内核线程实现
    • 使用用户线程实现
    • 使用用户线程加轻量级进程混合实现
  • 线程调度是指系统未线程分配处理器使用权的过程,主要调度方式有两种,分别是:
    • 协同式线程调度,线程的执行时间由线程本身来控制,线程工作完了主动通知系统切换到另外一个线程上;
    • 抢占式线程调度,每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定;但可以通过设置优先级来让系统多分配一些执行时间,但线程优先并不太靠谱;
  • 一个线程有且只有以下的一种状态:
    • 新建(New):创建后尚未启动的线程状态
    • 运行(Runable):包括了操作系统线程状态中的Running和Ready,可能正在执行,也可能正在等待CPU为它分配执行时间;
    • 无限期等待(Waiting):出于这种状态的线程不会被分配CPU执行时间,他们要等待其他线程显示的唤醒。以下方法会让线程陷入无限期的等待状态:
      • 没有设置TimeOut参数的Object.wait()方法。
      • 没有设置TimnOut参数的Thread.join()方法。
      • LockSupport.park()方法。
    • 限期等待(Timed Waiting):处于这种状态的线程不会被分配CPU执行时间,不过无须等待其他线程显示的唤醒,在一定时间后他们由系统自动唤醒。以下方法会让线程陷入限期等待状态:
      • 设置了TimeOut参数的Object.wait()方法。
      • 设置了TimnOut参数的Thread.join()方法。
      • LockSupport.parkNanos()方法。
      • LockSupport.parkUnitl()方法。
    • 阻塞(Blocked):线程阻塞与线程等待的区别是:阻塞状态在等待这获取到一个排他锁,这个时间将在另外一个线程放弃这个锁的时候发生;等待状态是在等待一段时间或者唤醒动作的发生。在程序等待进入同步区域的时候,线程会进入阻塞状态;
    • 结束(Terminated):线程已经结束执行的状态;

线程安全与锁优化
  • 线程安全的实现方法:
    • 互斥同步:同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。因此,在这4个字里边,互斥是因,同步是果;互斥是方法,同步是目的。
      • 在Java中,最基本的互斥同步手段就是synchronized关键字,synchronized关键字在编译以后,会在同步块前后分别形成monitorenter和monitorexit这两个指令,这两个指令都需要一个reference类型的参数来指明要锁定和解锁的对象。
      • synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。同步块在已进入的线程执行完之前,会阻塞后面的其他线程进入。
      • Java的线程是映射到操作系统的原生线程之上的,如果要唤醒或阻塞一个线程,都需要操作系统来帮忙完成,这就需要从用户态切到核心态中,因此状态转换需要耗费很多处理器时间(这也是为什么会出现偏向锁等锁优化措施);
      • java.utile.concurrent包中的重入锁(ReentrantLock)也可以实现同步,与synchronized,ReentrantLock增加了一些高级功能,如:等待可中断,可实现公平锁,以及锁可以绑定多个条件等
    • 非阻塞同步:互斥同步最主要的问题就是进行线程的阻塞和唤醒带来的性能问题,因此也可以被称之为阻塞同步。互斥同步属于一种悲观的并发策略,而非阻塞同步属于基于冲突检测的乐观并发策略,通俗的说,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施是不断的重试,直到成功为止)。
      • 这种策略需要操作和冲突检测这两个步骤具备原子性,需要靠硬件来完成。
    • 无同步方案:以下两种情况不需要进行同步操作来保证线程安全
      • 可重入代码:纯代码,在代码中任何时刻中断它,等控制权返回后,原来的程序不会出现任何错误;可重入代码有以下特征:不依赖存储在堆上的数据和公共的系统资源、用到的状态量都是由参数中传入、不调用非可重入的方法等。判断原则:如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,它就能返回相同的结果,它就满足可重入性的要求,当然也就是线程安全的。
      • 线程本地存储:如果一段代码中所需的数据必须与其他代码共享,如果能保证这些共享数据的代码在同一个线程中执行,我们就可以把共享数据的可见范围限制在同一个线程之内。
  • 锁优化:
    • 自旋锁:如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面的请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间(即不进行阻塞),看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
    • 自适应自旋锁:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
    • 锁消除:锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,就可以把它们当成栈上数据对待,认为它们是线程私有的,同步锁自然就无需进行。
    • 锁粗化:如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把锁的同步范围扩展到整个操作序列的外部。
    • 轻量级锁:由对象头配合实现的一种机制,它并不是来代替重量级锁的,它的本意是指在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的。”
    • 偏向锁:如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下,把整个不同都消除掉,连CAS操作也不做了。偏向锁,它会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远都不需要在进行同步。但是当一个线程获得对象的偏向锁以后,当有另外一个线程去尝试获取这个锁时,偏向模式宣告结束。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335

推荐阅读更多精彩内容