码农戏码

新生代农民工的自我修养


  • 首页

  • 归档

  • 标签

  • 关于

  • 在线工具

  • 搜索

自定义类加载器

发表于 2018-10-10 | 分类于 java
字数统计: 3.1k 字数 | 阅读时长 ≈ 13 分钟

1、为什么需要自定义类加载器

  1. 在《类加载器》中讲的,默认类加载器只能加载固定路径下的class,如果有特定路径下的class,需要自定义
  2. 安全性:系统自身需要一些jar,class,如果业务类代码中也有相同的class,破坏系统,类似双亲委托安全性

可以看看tomcat自定义类加载器的原因,别的就大同小异了

1
2
3
4
5
6
7
a)、要保证部署在tomcat上的每个应用依赖的类库相互独立,不受影响。
b)、由于tomcat是采用java语言编写的,它自身也有类库依赖,为了安全考虑,tomcat使用的类库要与部署的应用的类库相互独立。
c)、有些类库tomcat与部署的应用可以共享,比如说servlet-api,使用maven编写web程序时,servlet-api的范围是provided,
表示打包时不打包这个依赖,因为我们都知道服务器已经有这个依赖了。
d)、部署的应用之间的类库可以共享。这听起来好像与第一点相互矛盾,但其实这很合理,类被类加载器加载到虚拟机后,
会生成代表该类的class对象存放在永久代区域,这时候如果有大量的应用使用spring来管理,如果spring类库不能共享,
那每个应用的spring类库都会被加载一次,将会是很大的资源浪费。

2、自定义加载器

这儿主要说下我司的自定义类加载器;更复杂点的可以看看tomcat的类加载机制

为什么需要自定义类加载器?这可以参考章节1的答案

主要在于应用与基础平台的隔离,相对应用:可以有更大技术选型自由度,不用考虑基础平台的jar包版本、相对平台:更可靠安全,不被应用class影响

类加载器结构

虽然JAVA使用了类加载的委派机制,但并没严格要求开发者必须遵守该机制,我们可以打破这种”双亲委派”机制

目录结构

目录 说明
/servicesdir 业务实现jar包
/thirddir 业务依赖jar包
/platformdir 平台依赖jar包

类加载器

  • 1.PlatformClassLoader平台加载器
    • 1.1.加载/platformdir下的jar包
    • 1.2.在加载时,采用了默认的“双亲委派”
  • 2.AppClassLoader应用加载器
    • 2.1.加载/servicesdir,/thirddir下的jar
    • 2.2.该类加载器一定程度上打破了默认的“双亲委派”
      • 2.2.0.loadClass方法中,如果本加载器没有load到对应的类,则会检查该类是否处于平台类加载器白名单中:
      • 2.2.1.如果处于白名单中,则委派PlatformClassLoader加载
      • 2.2.2.否则,通过super.loadClass(String,boolean)走默认的双亲委派

此处白名单类:平台核心类,不能被同名业务类干扰

预加载

《类加载器》中说过,程序启动后,并不会加载所有类,在运行中实现到时,才会去加载。这儿就有性能损耗。

按类加载规则,一个类只加载一次

可以测试一下,加载需要的损耗

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 类加载时间性能测试
*
* 看一下类加载需要消耗的时间
* Created by Jack on 2018/10/8.
*/
public class ClassLoaderTest1 {
public static void main(String[] args) throws SQLException {
long s = System.nanoTime();

LoaderClass loaderClass = new LoaderClass();
long e = System.nanoTime();
//第一次时间
System.out.println(e - s);
e = System.nanoTime();
//第二次实例,但已经加载过,不再需要加载
LoaderClass loaderClass1 = new LoaderClass();
long e1 = System.nanoTime();
//第二次时间
System.out.println(e1 - e);
}
}
//输出
2409737
396

可以从输出看到性能损耗是不小的,这部分损耗可以通过预加载来消除

随着程序运行时间越久,被触发的业务越多,那加载到的业务类越多。

预加载类的逻辑

ClassWarmUp

  • 1.在classloader中loadClass时,把className加入到LinkedBlockingDeque中
  • 2.为了性能,异步把deque中的class写入到文件中,需要起一个后台线程
    • 2.1 后台线程,从deque中取出class,写入到文件中
  • 3.下次从文件中预先加载class

打包

对于/servicesdir 与 /thirddir 都好处理,但对于platformdir是怎么打包的呢?毕竟在开发时,只是引入一个平台基础jar就行

使用

有了自定义类加载器,在应用主函数中,就不能直接new了,不然就会使用AppClassLoader

所以需要使用反射机制

1
2
3
Class<?> loadClass = platformClassLoader.loadClass("com.jack.Start");
Method startMethod = loadClass.getMethod("startUp");
startMethod.invoke(loadClass);

这样,通过Start加载的类也会通过platformClassLoader去加载

创建springcontext也一样,这儿还需使用到Thread.currentThread().getContextClassLoader()【下面有详解】

1
2
3
4
5
6
7
8
9
10
11
12
ClassLoader currentThreadLoader = Thread.currentThread().getContextClassLoader();
Thread.currentThread().setContextClassLoader(appClassLoader);

Class<?> contextClass = appClassLoader
.loadClass("org.springframework.context.support.FileSystemXmlApplicationContext");
Class<?>[] parameterTypes = new Class[] { String[].class };
Constructor<?> constructor = contextClass.getConstructor(parameterTypes);
return constructor.newInstance(new Object[] { xmlPaths.toArray(new String[0]) });


// switch back the thread context classloader
Thread.currentThread().setContextClassLoader(currentThreadLoader);

3、反常

“双亲委派”模型有优点,也有力不从心的地方

Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器(Bootstrap Classloader)来加载的;SPI的实现类是由系统类加载器(System ClassLoader)来加载的。引导类加载器是无法找到 SPI 的实现类的,因为依照双亲委派模型,BootstrapClassloader无法委派AppClassLoader来加载类。
而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。

场景:

  1. 当高层提供了统一的接口让低层去实现,同时又要在高层加载(或者实例化)低层的类时,就必须要通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类
  2. 当使用本类托管类加载,然而加载本类的ClassLoader未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管

解决方案:

从jdk1.2开始引入的,类Thread中的getContextClassLoader()与setContextClassLoader(ClassLoader c1),分别用来获取和设置类加载器

一般使用模式:获取-使用-还原

1
2
3
4
5
6
7
ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); 
try{
Thread.currentThread().setContextClassLoader(targetTccl);
excute();
} finally {
Thread.currentThread().setContextClassLoader(classLoader);
}

jdbc

以jdbc看下场景1的情况

1
2
3
4
Class.forName("com.mysql.jdbc.Driver")
String url = "jdbc:mysql://localhost:3306/testdb";
// 通过java库获取数据库连接
Connection conn = java.sql.DriverManager.getConnection(url, "name", "password");
  • 1.Class.forName(“com.mysql.jdbc.Driver”); 在com.mysql.jdbc.Driver中
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class Driver extends NonRegisteringDriver implements java.sql.Driver {
    static {
    try {
    java.sql.DriverManager.registerDriver(new Driver());
    } catch (SQLException E) {
    throw new RuntimeException("Can't register driver!");
    }
    }

    public Driver() throws SQLException {
    // Required for Class.forName().newInstance()
    }
    }

通过Class.forName(),主要就是执行初始化static代码块,也就是向DriverManager注册Driver

此时:应用类、Driver是由AppClassLoader加载,但由于双亲委派java.sql.DriverManager是由BootstrapClassLoader加载

  • 2.java.sql.DriverManager.getConnection 获取连接
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    private static Connection getConnection(
    String url, java.util.Properties info, ClassLoader callerCL) throws SQLException {
    java.util.Vector drivers = null;

    synchronized(DriverManager.class) {
    if(callerCL == null) {
    callerCL = Thread.currentThread().getContextClassLoader();
    }
    }

    if(url == null) {
    throw new SQLException("The url cannot be null", "08001");
    }

    println("DriverManager.getConnection(\"" + url + "\")");

    if (!initialized) {
    initialize();
    }

    synchronized (DriverManager.class){
    drivers = readDrivers;
    }

    SQLException reason = null;
    for (int i = 0; i < drivers.size(); i++) {
    DriverInfo di = (DriverInfo)drivers.elementAt(i);

    if ( getCallerClass(callerCL, di.driverClassName ) != di.driverClass ) {
    println(" skipping: " + di);
    continue;
    }
    try {
    println(" trying " + di);
    Connection result = di.driver.connect(url, info);
    if (result != null) {
    // Success!
    println("getConnection returning " + di);
    return (result);
    }
    } catch (SQLException ex) {
    if (reason == null) {
    reason = ex;
    }
    }
    }

    if (reason != null) {
    println("getConnection failed: " + reason);
    throw reason;
    }

    println("getConnection: no suitable driver found for "+ url);
    throw new SQLException("No suitable driver found for "+ url, "08001");
    }


    private static Class getCallerClass(ClassLoader callerClassLoader,
    String driverClassName) {
    Class callerC = null;

    try {
    callerC = Class.forName(driverClassName, true, callerClassLoader);
    }
    catch (Exception ex) {
    callerC = null; // being very careful
    }

    return callerC;
    }

这其中有两行代码:

1
2
3
callerCL = Thread.currentThread().getContextClassLoader();

callerC = Class.forName(driverClassName, true, callerClassLoader);

这儿是取线程上下文中的classloader,也就是AppClassLoader;如果不取此classloader,那么Class.forName(driverClassName)就是使用DriverManager的BootstrapClassLoader加载,那必然是加载不到,这也就是父层类加载器加载不了低层类。

还有个问题,为什么在应用程序中已经加载过Driver,到了getConnection()又要再加载,还得通过Thread.currentThread().getContextClassLoader()?

其实在getConnection()中,只是对比class是否是同一个,像tomcat那样,各个应用都有自己的mysql-driver的jar包,就只能通过classloader来区分,因为class是不是相同需要classname+classloader组合鉴别

spring

对于场景2的问题

如果有 10 个 Web 应用程序都用到了spring的话,可以把Spring的jar包放到 common 或 shared 目录下让这些程序共享。Spring 的作用是管理每个web应用程序的bean,getBean时自然要能访问到应用程序的类,而用户的程序显然是放在 /WebApp/WEB-INF 目录中的(由 WebAppClassLoader 加载),那么在 CommonClassLoader 或 SharedClassLoader 中的 Spring 容器如何去加载并不在其加载范围的用户程序(/WebApp/WEB-INF/)中的Class呢?

答案呼之欲出:spring根本不会去管自己被放在哪里,它统统使用线程上下文加载器来加载类,而线程上下文加载器默认设置为了WebAppClassLoader,也就是说哪个WebApp应用调用了spring,spring就去取该应用自己的WebAppClassLoader来加载bean

org.springframework.web.context.ContextLoader类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
if (servletContext.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null) {
throw new IllegalStateException(
"Cannot initialize context because there is already a root application context present - " +
"check whether you have multiple ContextLoader* definitions in your web.xml!");
}

Log logger = LogFactory.getLog(ContextLoader.class);
servletContext.log("Initializing Spring root WebApplicationContext");
if (logger.isInfoEnabled()) {
logger.info("Root WebApplicationContext: initialization started");
}
long startTime = System.currentTimeMillis();

try {
// Determine parent for root web application context, if any.
ApplicationContext parent = loadParentContext(servletContext);

// Store context in local instance variable, to guarantee that
// it is available on ServletContext shutdown.
this.context = createWebApplicationContext(servletContext, parent);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);

ClassLoader ccl = Thread.currentThread().getContextClassLoader();
if (ccl == ContextLoader.class.getClassLoader()) {
currentContext = this.context;
}
else if (ccl != null) {
currentContextPerThread.put(ccl, this.context);
}

if (logger.isDebugEnabled()) {
logger.debug("Published root WebApplicationContext as ServletContext attribute with name [" +
WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE + "]");
}
if (logger.isInfoEnabled()) {
long elapsedTime = System.currentTimeMillis() - startTime;
logger.info("Root WebApplicationContext: initialization completed in " + elapsedTime + " ms");
}

return this.context;
}
catch (RuntimeException ex) {
logger.error("Context initialization failed", ex);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, ex);
throw ex;
}
catch (Error err) {
logger.error("Context initialization failed", err);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, err);
throw err;
}
}

关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取线程上下文类加载器,默认为WebAppClassLoader
ClassLoader ccl = Thread.currentThread().getContextClassLoader();
// 如果spring的jar包放在每个webapp自己的目录中
// 此时线程上下文类加载器会与本类的类加载器(加载spring的)相同,都是WebAppClassLoader
if (ccl == ContextLoader.class.getClassLoader()) {
currentContext = this.context;
}
else if (ccl != null) {
// 如果不同,也就是上面说的那个问题的情况,那么用一个map把刚才创建的WebApplicationContext及对应的WebAppClassLoader存下来
// 一个webapp对应一个记录,后续调用时直接根据WebAppClassLoader来取出
currentContextPerThread.put(ccl, this.context);
}

这样做的目的在于当通过ConetxtLoader的静态方法获取context的时候,能保证获取的是当前web application的context.实际上就是对于tomcat下面的任何一个线程,我们都能很方便的找出这个线程对应的webapplicationContext.于是在一些不能方便获取servletContext的场合,我们可以通过当前线程获取webapplicationContext.

1
2
3
4
5
6
7
8
9
10
public static WebApplicationContext getCurrentWebApplicationContext() {
ClassLoader ccl = Thread.currentThread().getContextClassLoader();
if (ccl != null) {
WebApplicationContext ccpt = currentContextPerThread.get(ccl);
if (ccpt != null) {
return ccpt;
}
}
return currentContext;
}

总结

简而言之就是ContextClassLoader默认存放了AppClassLoader的引用,由于它是在运行时被放在了线程中,所以不管当前程序处于何处(BootstrapClassLoader或是ExtClassLoader等),在任何需要的时候都可以用Thread.currentThread().getContextClassLoader()取出应用程序类加载器来完成需要的操作

4、参考资料

以jdbc为例搞清contextClassLoader

类加载器

发表于 2018-10-10 | 分类于 java
字数统计: 6k 字数 | 阅读时长 ≈ 23 分钟

java执行过程

先回顾一下要执行java程序,需要经过哪些步骤

执行java程序

  1. 编写java代码
  2. 通过javac把源代码编译成class
  3. 把class载入JVM

1、2两步是需要开发人员参与的,而第3步是JVM的行为,对开发人员透明

JVM类加载

详细看下第三点,class载入JVM过程

从内存空间视角,会分配到各个空间:

内存结构

每个内存空间详情可参考:《GC及JVM参数》

从类生命周期角度,分阶段:

类生命周期

其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。

1.加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口

在加载阶段,虚拟机需要完成以下3件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

加载.class文件的方式

  • 从本地系统中直接加载
  • 通过网络下载.class文件
  • 从zip,jar等归档文件中加载.class文件
  • 从专有数据库中提取.class文件
  • 将Java源文件动态编译为.class文件

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,
而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据

加载时机

  • 当应用程序启动的时候,所有的类不会被一次性加载,因为如果一次性加载,内存资源有限,可能会影响应用程序的正常运行。例如,A a=new A(),
    一个类真正被加载的时机是在创建对象的时候,才会去执行以上过程,加载类。当我们测试的时候,最先加载拥有main方法的主线程所在类

  • Java虚拟机有预加载功能。类加载器并不需要等到某个类被”首次主动使用”时再加载它,JVM规范规定JVM可以预测加载某一个类,如果这个类出错,但是应用程序没有调用这个类, JVM也不会报错;如果调用这个类的话,JVM才会报错,(LinkAgeError错误)

加载方式

隐式加载
  1. 创建类对象
  2. 使用类的静态域
  3. 创建子类对象
  4. 使用子类的静态域
  5. 在JVM启动时,BootStrapLoader会加载一些JVM自身运行所需的class
  6. 在JVM启动时,ExtClassLoader会加载指定目录下一些特殊的class
  7. 在JVM启动时,AppClassLoader会加载classpath路径下的class,以及main函数所在的类的class文件
显式加载
  1. ClassLoader.loadClass(className),不会进行初始化
  2. Class.forName(String name, boolean initialize,ClassLoader loader); 借助当前调用者的class的ClassLoader完成class的加载,加载class的同时根据initialize是否初始化

2.连接

2.1.验证:确保被加载的类的正确性

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致会完成4个阶段的检验动作:

文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。

元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。

字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

符号引用验证:确保解析动作能正确执行。

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

2.2. 准备:为类的静态变量分配内存,并将其初始化为默认值

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:

  • 2.2.1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
  • 2.2.2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
    假设一个类变量的定义为:public static int value = 3;
    那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行
  • 2.2.3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。

    假设上面的类变量value被定义为: public static final int value = 3;

    编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3

2.3. 解析:虚拟机将常量池中的符号引用替换为直接引用(内存地址)的过程

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

常量池
  1. 字面量:比较接近Java语言层面,如String字符串,声明final的常量等
  2. 符号引用:属于编译原理方面的概念:1、包括类和接口的全限定名 2、字段的名称和描述符3.方法的名称和描述符
常量项结构

常量项结构

这些内容,需要再去分析class文件详细结构,后续再学习了

3.初始化,为类的静态变量赋予正确的初始值

类加载的最后一个阶段,除了加载阶段我们可以通过自定义类加载器参与之外,其余完全又JVM主导。到了初始化阶段,才真正开始执行程序,也就是由java转换成的class

JVM负责对类进行初始化,主要对类变量进行初始化。

在Java中对类变量进行初始值设定有两种方式:

  1. 声明类变量是指定初始值

  2. 使用静态代码块为类变量指定初始值

JVM初始化规则

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化

Java程序对类的使用方式可以分为两种:

  1. 主动使用:会执行加载、连接、初始化静态域
  2. 被动使用:只执行加载、连接,不执行类的初始化静态域
类的主动使用包括以下六种:
  • 创建类的实例,如(1)new (2)反射newInstance (3)序列化生成obj;遇到new、getstatic、putstatic、invokestatic这四条字节码指令
  • 访问某个类或接口的静态变量,或者对该静态变量赋值 (注意static 与static final的区别)
  • 调用类的静态方法
  • 反射(如Class.forName(“Test”))
  • 初始化某个类的子类,则其父类也会被初始化;接口初始化不会导致父接口的初始化(这其实也是static final的原因);对于静态字段,
    • 只有直接定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化
  • Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类
被动使用,不在主动使用的六种以内都是被动的
  • 1.如通过子类引用父类的静态字段,为子类的被动使用,不会导致子类初始化
  • 2.通过数组定义类引用类,为类的被动使用,不会触发此类的初始化
    • 2.1 原因:其实数组已经不是E类型了,E的数组jvm在运行期,会动态生成一个新的类型,新类型为:
      如果是一维数组,则为:[L+元素的类全名;二维数组,则为[[L+元素的类全名
      如果是基础类型(int/float等),则为[I(int类型)、[F(float类型)等
      
  • 3.常量在编译阶段会存入调用方法所在的类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 主动 被动使用问题测试
* Created by Jack on 2018/9/28.
*/
public class ClassInitTest3 {
public static void main(String[] args) {
String x = F.s;
}
}

class F {
//因为UUID.randomUUID().toString()这个方法,是运行期确认的,所以,这不是被动使用
static final String s = UUID.randomUUID().toString();

static {
//这儿会被输出
System.out.println("Initialize class F");
}
}

clinit 与 init

在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init

clinit:

clinit指的是类构造器,这个构造器是jvm自动合并生成的,在jvm第一次加载class文件时调用,包括静态变量初始化语句和静态块的执行

它合并static变量的赋值操作

  1. 注意是赋值操作,(仅声明,或者final static)不会触发,毕竟前面准备阶段已经默认赋过值为0了
  2. static{}语句块生成,且虚拟机保证执行前,父类的已经执行完毕,所以说父类如果定义static块的话,一定比子类先执行
  3. 如果一个类或接口中没有static变量的赋值操作和static{}语句块,那么不会被JVM生成
  4. static变量的赋值操作和static{}语句块合并的顺序是由语句在源文件中出现的顺序所决定的。
init:

在实例创建出来的时候调用,也就是构造函数,包括:

  1. new操作符
  2. 普通代码块
  3. 调用Class或java.lang.reflect.Constructor对象的newInstance()方法;
  4. 调用任何现有对象的clone()方法;
  5. 通过java.io.ObjectInputStream类的getObject()方法反序列化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* <clinit> 与 <init> 区别
*/
public class ClassInitTest2 {
static {
System.out.println("cinit");

i = 3;//可以赋值
//System.out.println(i);//但不能使用,语法错误
}

private static int i = 1;

{
System.out.println("init");//实例化构造器,
}

public static void main(String [] args) {
new ClassInitTest2();
new ClassInitTest2();
String str = "str";
System.out.println(str);
}
}

// 输出
cinit
init
init
str

static 与 static final 对初始化的区别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* static 与 static final 对初始化的区别
*/
public class ClassInitFinalTest {
public static int age = 20;

static {
//如果age定义为static final,这儿就不会执行
System.out.println("静态初始化!");
}

public static void main(String args[]){
System.out.println(ClassInitFinalTest.age);
}
}

不会执行类初始化的几种情况

  • 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  • 定义对象数组,不会触发该类的初始化
  • 类A引用类B的static final常量不会导致类B初始化 (看上面的ClassInitFinalTest)
  • 通过类名获取Class对象,不会触发类的初始化。如
  • System.out.println(Person.class);
  • 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  • 通过ClassLoader默认的loadClass方法,也不会触发初始化动作

结束生命周期

在如下几种情况下,Java虚拟机将结束生命周期

  • 执行了System.exit()方法
  • 程序正常执行结束
  • 程序在执行过程中遇到了异常或错误而异常终止
  • 由于操作系统出现错误而导致Java虚拟机进程终止

类加载测试

看到一段代码,很有意思

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 测试类加载及初始化顺序问题
* Created by jack01.zhu on 2018/9/28.
*/
public class ClassInit {
private static ClassInit singleton = new ClassInit();
public static int counter1;
public static int counter2 = 0;
private ClassInit() {
counter1++;
counter2++;
}
public static ClassInit getSingleton() {
return singleton;
}
}

/**
* 通过输出结果,推测类加载过程
* Created by jack01.zhu on 2018/9/28.
*/
public class ClassInitTestMain {

public static void main(String []args) {
ClassInit classInitTest = ClassInit.getSingleton();
System.out.println("counter1="+classInitTest.counter1);
System.out.println("counter2="+classInitTest.counter2);
}
}

这段代码输出的结果是什么?

1
2
counter1=1
counter2=0

  1. 入口肯定是ClassInitTestMain.main(),从这儿开始加载,初始化
  2. ClassInit.getSingleton(),首次使用化,所以从加载部分开始执行,执行到准备阶段所有static变量都被设置为初始值。此时

    1
    2
    3
    public static int counter1 = 0;
    public static int counter2 = 0;
    private static ClassInit singleton = null;
  3. ClassInit执行到初始化阶段,生成类构造器,类构造器会合并 static变量的赋值操作和 static语句块。合并后执行

    1
    2
    3
    4
    5
    6
    public static int counter1 ; // 由于 counter1没被赋值,所以不会被合并进去

    public void clinit() {// 伪代码:<clinit>方法体内容
    ClassInit singleton = new ClassInit();//(1)
    int counter2 = 0;// (2)
    }
  4. 初始化阶段 执行clinit内代码,执行到(1)处,此时counter1和counter2都变为1。

  5. 初始化阶段 执行clinit内代码,执行到(2)处,counter2又被设置为0。
  6. 初始化结束 ,回到Main方法的ClassInit.getSingleton();继续执行main方法,最后输出结束。

以上,就是一个类的生命周期,这篇重点就是加载部分,如上面所说,加载阶段相对别的阶段,对开发人员而言有更强的可控性;下面学习一下类加载器相关知识

类加载器

类加载器

  1. BootstrapClassLoader:加载路径: System.getProperty(“java.class.path”) 或直接通过 -Xbootclasspath 指定

    特性: 用C语言写的

    手动获取加载路径: sun.misc.Launcher.getBootstrapClassPath().getURLs()

  2. ExtClassLoader:加载路径: System.getProperty(“java.ext.dirs”) 或直接通过 -Djava.ext.dirs 指定

    特性: 继承 URLClassLoader

    手动获取加载路径:((URLClassLoader)App.class.getClassLoader().getParent()).getURLs()

  3. AppClassLoader:加载路径: System.getProperty(“sun.boot.class.path”) 或直接通过 -cp, -classpath 指定

    特性: 继承 URLClassLoader

    手动获取加载路径: ((URLClassLoader)App.class.getClassLoader()).getURLs()
    通过 ClassLoader.getSystemClassLoader() 就可以获取 AppClassLoader, 自己写的程序中写的 ClassLoader(继承 URLClassLoader), 若不指定 parent, 默认的parent就是 AppClassLoader

同一个class

在JVM中,如何确定一个类型实例:

同一个Class = 相同的 ClassName + PackageName + ClassLoader

在JVM中,类型被定义在一个叫SystemDictionary 的数据结构中,该数据结构接受类加载器和全类名作为参数,返回类型实例。

SystemDictionary 如图所示:

SystemDictionary

加载机制

  1. 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
  2. 双亲委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
  3. 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效

双亲委托模型

双亲委托的工作过程:如果一个类加载器收到了一个类加载请求,它首先不会自己去加载这个类,而是把这个请求委托给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成加载请求(它管理的范围之中没有这个类)时,子加载器才会尝试着自己去加载

  1. 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。

  2. 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。

  3. 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;

  4. 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

1
javac –verbose查看运行类是加载了jar文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{

// 首先检查,jvm中是否已经加载了对应名称的类,findLoadedClass(String )方法实际上是findLoadedClass0方法的wrapped方法,做了检查类名的工
//作,而findLoadedClass0则是一个native方法,通过底层来查看jvm中的对象。
Class c = findLoadedClass(name);
if (c == null) {//类还未加载
try {
if (parent != null) {
//在类还未加载的情况下,我们首先应该将加载工作交由父classloader来处理。
c = parent.loadClass(name, false);
} else {
//返回一个由bootstrap class loader加载的类,如果不存在就返回null
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found

// from the non-null parent class loader
}

if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
c = findClass(name);//这里是我们的入手点,也就是指定我们自己的类加载实现
}
}
if (resolve) {
resolveClass(c);//用来做类链接操作
}
return c;
}

从上面的方法也看出我们在实现自己的加载器的时候,不要覆盖locaClass方法,而是重写findClass(),这样能保证双亲委派模型,同时也实现了自己的方法

为什么要使用双亲委托这种模型呢?

  1. 节约系统资源: 因为这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次
  2. 保证Java核心库的类型安全: 我们试想一下,如果不使用这种委托模式,那我们就可以随时使用自定义的String来动态替代java核心api中定义的类型,这样会存在非常大的安全隐患,而双亲委托的方式,就可以避免这种情况,因为String已经在启动时就被引导类加载器(Bootstrcp ClassLoader)加载,所以用户自定义的ClassLoader永远也无法加载一个自己写的String,除非你改变JDK中ClassLoader搜索类的默认算法。

自定义加载器

既然JVM已经提供了默认的类加载器,为什么还要定义自已的类加载器呢?

因为Java中提供的默认ClassLoader,只加载指定目录下的jar和class,如果我们想加载其它位置的类或jar时,比如:我要加载网络上的一个class文件,通过动态加载到内存之后,要调用这个类中的方法实现业务逻辑。在这样的情况下,默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader

定义自已的类加载器分为两步:

  1. 继承java.lang.ClassLoader
  2. 重写父类的findClass方法

示例

很简单的两个类,方法中打印出各自的类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class LoaderClass {

public void loader(){
System.out.println("LoaderClass:"+this.getClass().getClassLoader());
LoaderClass1 class1 = new LoaderClass1();
class1.loader();
}
}

public class LoaderClass1 {

public void loader() {
System.out.println(this.getClass().getName() + " loader:"+this.getClass().getClassLoader());

}
}

自定义加载器

  1. 重写findClass方法,从class文件加载
  2. 通过defineClass从bytes构建class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class MyClassLoader extends ClassLoader {

protected Class<?> findClass(String name) throws ClassNotFoundException {

String root = "d:/";

byte[] bytes = null;
try {
//路径改到根目录下
String file = root + name.substring(name.lastIndexOf(".")+1) + ".class";
InputStream ins = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
bytes = baos.toByteArray();

ins.close();
} catch (Exception e) {
e.printStackTrace();
}
return defineClass(name, bytes, 0, bytes.length);
}
}

测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ClassLoaderTest {

public static void main(String[]args) throws Exception {
ClassLoaderTest test = new ClassLoaderTest();

System.out.println(test.getClass().getClassLoader());//输出sun.misc.Launcher$AppClassLoader

System.out.println(test.getClass().getClassLoader().getParent());//输出sun.misc.Launcher$ExtClassLoader

System.out.println(test.getClass().getClassLoader().getParent().getParent());//输出null

//=====测试重复加载,类路径中LoaderClass.class存在=================
//======虽然指定了classloader,但依然输出的是LoaderClass:sun.misc.Launcher$AppClassLoader
//==删除类路径下的LoaderClass.class,才会输出LoaderClass:com.jack.classloader.MyClassLoader
//并且loaderclass中创建的对象类加载器也是MyClassLoader
MyClassLoader classLoader = new MyClassLoader();
Class<?> loadClass = Class.forName("com.jack.classloader.LoaderClass", true, classLoader);
Method startMethod = loadClass.getMethod("loader");
startMethod.invoke(loadClass.newInstance());

//===当类加载器不一样时,两个class不相等
MyClassLoader classLoader1 = new MyClassLoader();
Class<?> loadClass1 = Class.forName("com.jack.classloader.LoaderClass", true, classLoader1);
System.out.println(loadClass.equals(loadClass1));//输出false
}
}

参考资料

class加载时机及两种显示加载的区别

JVM类加载机制—类加载的过程

<init>和<clinit>

类加载原理分析&动态加载Jar/Dex

java类的主动使用/被动使用

FastThreadLocal解析

发表于 2018-10-05
字数统计: 1.3k 字数 | 阅读时长 ≈ 6 分钟

前言

之前《TreadLocal解析》说过Threadlocal的结构:

ThreadLocal结构

但netty却重新搞了一个fastthreadlocal,从各方面对比一下两者的区别。也不得不说一下netty真不愧是款优秀框架,里面中有很多优秀类和方法值得细品

VS ThreadLocal

1、性能

第一点,从性能开始,为什么要重造轮子,可能就是之前的轮子达不到性能要求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class FastThreadLocalTest {


public static void main(String[] args) {
testFast(100);
testSlow(100);
}

private static void testFast(int threadLocalCount) {
final FastThreadLocal<String>[] caches = new FastThreadLocal[threadLocalCount];
final Thread mainThread = Thread.currentThread();
for (int i = 0; i < threadLocalCount; i++) {
caches[i] = new FastThreadLocal();
}
Thread t = new FastThreadLocalThread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < threadLocalCount; i++) {
caches[i].set("float.lu");
}
long start = System.nanoTime();
for (int i = 0; i < threadLocalCount; i++) {
for (int j = 0; j < 1000000; j++) {
caches[i].get();
}
}
long end = System.nanoTime();
System.out.println("take[" + TimeUnit.NANOSECONDS.toMillis(end - start) +
"]ms");
LockSupport.unpark(mainThread);
}

});
t.start();
LockSupport.park(mainThread);
}

private static void testSlow(int threadLocalCount) {
final ThreadLocal<String>[] caches = new ThreadLocal[threadLocalCount];
final Thread mainThread = Thread.currentThread();
for (int i=0;i<threadLocalCount;i++) {
caches[i] = new ThreadLocal();
}
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i=0;i<threadLocalCount;i++) {
caches[i].set("float.lu");
}
long start = System.nanoTime();
for (int i=0;i<threadLocalCount;i++) {
for (int j=0;j<1000000;j++) {
caches[i].get();
}
}
long end = System.nanoTime();
System.out.println("take[" + TimeUnit.NANOSECONDS.toMillis(end - start) +
"]ms");
LockSupport.unpark(mainThread);
}

});
t.start();
LockSupport.park(mainThread);
}
}

//输出
fast[15]ms
slow[302]ms

从输出可见性能提升很大

2、数据结构

两者的数据结构大体相似,都是thread带上map属性,threadlocal实例为key;但在细节算法处理时,不一样

get()

整体思路:通过thread取到map,再从map中取value

ThreadLocal.get()

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

从map中取值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

如果key值相等,直接返回value

如果key不相等,使用循环线性探测,一直找到最后一个元素

FastThreadLocal.get()

1
2
3
4
5
6
7
8
9
10
11
12
13
public final V get(InternalThreadLocalMap threadLocalMap) {
Object v = threadLocalMap.indexedVariable(index);
if (v != InternalThreadLocalMap.UNSET) {
return (V) v;
}

return initialize(threadLocalMap);
}

public Object indexedVariable(int index) {
Object[] lookup = indexedVariables;
return index < lookup.length? lookup[index] : UNSET;
}

这个明显就快些,有index,直接数组拿值,不需要再去处理循环

set()

主要在于向map中放值

ThreadLocal.set()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

private void set(ThreadLocal<?> key, Object value) {

// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.

Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
  1. 通过取模,得到index
  2. key相等,直接赋值value
  3. key不相等,那就线性探测存放

FastThreadLocal.set()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public final void set(V value) {
if (value != InternalThreadLocalMap.UNSET) {
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
if (setKnownNotUnset(threadLocalMap, value)) {
registerCleaner(threadLocalMap);
}
} else {
remove();
}
}

public boolean setIndexedVariable(int index, Object value) {
Object[] lookup = indexedVariables;
if (index < lookup.length) {
Object oldValue = lookup[index];
lookup[index] = value;
return oldValue == UNSET;
} else {
expandIndexedVariableTableAndSet(index, value);
return true;
}
}

这类似就是放入到数组中

总结

到此可以看出二者的区别

区别 ThreadLocal FastThreadLocal
map ThreadLocalMap InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap
线程 Thread FastThreadLocalThread extends Thread

主要还是在内部map的处理逻辑上,两者都没有使用hashmap,但是自定义了map结构与行为,在《hashmap源码解析》中指出map结构的两种处理方式:拉链法与线性探测法;在hasmap中使用的是拉链法,而threadlocal中使用的是线性探测法

线性探查(Linear Probing)方式虽然简单,但是有一些问题,它会导致同类哈希的聚集。在存入的时候存在冲突,在查找的时候冲突依然存在

冲突也就造成了性能损耗,而FastTreadLocal就更简单,直接使用数组

1
2
3
public FastThreadLocal() {
index = InternalThreadLocalMap.nextVariableIndex();
}

UnpaddedInternalThreadLocalMap

1
2
3
4
5
6
7
8
9
10
11
Object[] indexedVariables;


public static int nextVariableIndex() {
int index = nextIndex.getAndIncrement();
if (index < 0) {
nextIndex.decrementAndGet();
throw new IllegalStateException("too many thread-local indexed variables");
}
return index;
}

整个map就是一个数组结构,在每个thread中,每一个FastThreadLocal在创建时就指定了index,value就是数组元素

再识RPC-thrift

发表于 2018-09-22 | 分类于 源码解读
字数统计: 2.1k 字数 | 阅读时长 ≈ 10 分钟

RPC

原理

image

什么是Stub?

Stub是一段代码,用来转换RPC过程中传递的参数。处理内容包括不同OS之间的大小端问题。另外,Client端一般叫Stub,Server端一般叫Skeleton。

生产方式:

  1. 手动生成,比较麻烦;
  2. 自动生成,使用IDL(InterfaceDescriptionLanguate),定义C/S的接口

RPC的套路:

自古深情留不住 唯有套路留人心

RPC最本质的就是通过socket把方法信息传输到远程服务器并执行相应method

在java界的rpc框架的实现手法:

  • 服务端:socket + 反射
  • 客户端:动态代理 + socket

之前也解析过motain框架,《motain客服端分析》、《motain服务端分析》

thrift

由于我司框架是通过thrift改造,发现这个框架没有按java套路出牌,可能这是跨语言类RPC的套路,有必要了解一下

thrift最初由facebook开发用做系统内各语言之间的RPC通信 。2007年由facebook贡献到apache基金 ,08年5月进入apache孵化器,支持多种语言之间的RPC方式的通信:php语言client可以构造一个对象,调用相应的服务方法来调用java语言的服务 ,跨越语言的C/S RPC调用   

thrift

示例

IDL文件

1
2
3
4
5
//HelloService.thrfit
namespace java com.jack.thrift
service HelloService{
string helloString(1:string what)
}

生成代码

1
运行  thrift -gen HelloService.thrfit

会生成一个HelloService类

实现服务端与客服端

让服务端打印出客户端传入的参数

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ThriftServer {

/**
* 启动thrift服务器
* @param args
*/
public static void main(String[] args) throws Exception {
try {
System.out.println("服务端开启....");
TProcessor tprocessor = new HelloService.Processor<HelloService.Iface>(new HelloServiceImpl());
// 简单的单线程服务模型
TServerSocket serverTransport = new TServerSocket(9898);
TServer.Args tArgs = new TServer.Args(serverTransport);
tArgs.processor(tprocessor);
tArgs.protocolFactory(new TBinaryProtocol.Factory());
TServer server = new TSimpleServer(tArgs);
server.serve();
}catch (Exception e) {
e.printStackTrace();
}
}

}

客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class ThriftClient {

public static void main(String[] args) {
System.out.println("客户端启动....");
TTransport transport = null;
try {
transport = new TSocket("localhost", 9898, 30000);
// 协议要和服务端一致
TProtocol protocol = new TBinaryProtocol(transport);
HelloService.Client client = new HelloService.Client(protocol);
transport.open();
String result = client.helloString("哈哈");
System.out.println(result);
} catch (TTransportException e) {
e.printStackTrace();
} catch (TException e) {
e.printStackTrace();
} finally {
if (null != transport) {
transport.close();
}
}
}
}

解析

可以看出server,client代码相对很简单,主要看看生成的HelloService类,这个类就是stub代码

来看一下,这个类是如何封装,把method和args传输到远程的

client

1
2
HelloService.Client client = new HelloService.Client(protocol);
String result = client.helloString("哈哈");

关键点在HelloService.Client.helloString()方法

1
2
3
4
5
public String helloString(String what) throws org.apache.thrift.TException
{
send_helloString(what);
return recv_helloString();
}

发送消息

1
2
3
4
5
6
public void send_helloString(String what) throws org.apache.thrift.TException
{
helloString_args args = new helloString_args();
args.setWhat(what);
sendBase("helloString", args);
}
  1. 把args抽象成了一个类
  2. 属性赋值
  3. 发送

主要看下sendBase()方法

1
2
3
4
5
6
private void sendBase(String methodName, TBase<?,?> args, byte type) throws TException {
oprot_.writeMessageBegin(new TMessage(methodName, type, ++seqid_));
args.write(oprot_);
oprot_.writeMessageEnd();
oprot_.getTransport().flush();
}

  • 1.oprot_.writeMessageBegin 根据Protocol写数据,比如这儿使用的TBinaryProtocol,以二进制写数据
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public void writeMessageBegin(TMessage message) throws TException {
    if (strictWrite_) {
    int version = VERSION_1 | message.type;
    writeI32(version);
    writeString(message.name);
    writeI32(message.seqid);
    } else {
    writeString(message.name);
    writeByte(message.type);
    writeI32(message.seqid);
    }
    }

再深入看看怎么写二进制数据的

int类型

1
2
3
4
5
6
7
public void writeI32(int i32) throws TException {
inoutTemp[0] = (byte)(0xff & (i32 >> 24));
inoutTemp[1] = (byte)(0xff & (i32 >> 16));
inoutTemp[2] = (byte)(0xff & (i32 >> 8));
inoutTemp[3] = (byte)(0xff & (i32));
trans_.write(inoutTemp, 0, 4);
}

string类型,先写长度,再写bytes

1
2
3
4
5
6
7
8
9
public void writeString(String str) throws TException {
try {
byte[] dat = str.getBytes("UTF-8");
writeI32(dat.length);
trans_.write(dat, 0, dat.length);
} catch (UnsupportedEncodingException uex) {
throw new TException("JVM DOES NOT SUPPORT UTF-8");
}
}

这儿写最终还是使用Transport.write,比如这儿使用的TSocket

1
2
3
4
5
6
7
8
9
10
public void write(byte[] buf, int off, int len) throws TTransportException {
if (outputStream_ == null) {
throw new TTransportException(TTransportException.NOT_OPEN, "Cannot write to null outputStream");
}
try {
outputStream_.write(buf, off, len);
} catch (IOException iox) {
throw new TTransportException(TTransportException.UNKNOWN, iox);
}
}

就是写到

1
outputStream_ = new BufferedOutputStream(socket_.getOutputStream(), 1024);

  • 2.args.write(oprot_);
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    public void write(org.apache.thrift.protocol.TProtocol oprot, helloString_args struct) throws org.apache.thrift.TException {
    struct.validate();

    oprot.writeStructBegin(STRUCT_DESC);
    if (struct.what != null) {
    oprot.writeFieldBegin(WHAT_FIELD_DESC);
    oprot.writeString(struct.what);
    oprot.writeFieldEnd();
    }
    oprot.writeFieldStop();
    oprot.writeStructEnd();
    }

这就是写field,也就是向输出流里写参数内容

  • 3.oprot_.writeMessageEnd();
    这表示消息写完成了,各个协议处理不同,比如二进制就是空实现,但如json就需要写个”}”,以完成json格式

  • 4.oprot_.getTransport().flush(); 直接flush

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /**
    * Flushes the underlying output stream if not null.
    */
    public void flush() throws TTransportException {
    if (outputStream_ == null) {
    throw new TTransportException(TTransportException.NOT_OPEN, "Cannot flush null outputStream");
    }
    try {
    outputStream_.flush();
    } catch (IOException iox) {
    throw new TTransportException(TTransportException.UNKNOWN, iox);
    }
    }

client总结

整个发送消息就结束了,虽然没有按套路使用动态代理,而是通过生成的stub代码,把methodName,args给封装好了

server

服务端也没有通过反射的方式

主要逻辑在生成的HelloService$Processor类中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public static class Processor<I extends Iface> extends org.apache.thrift.TBaseProcessor<I> implements org.apache.thrift.TProcessor {
private static final org.slf4j.Logger _LOGGER = org.slf4j.LoggerFactory.getLogger(Processor.class.getName());
public Processor(I iface) {
super(iface, getProcessMap(new java.util.HashMap<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>>()));
}

protected Processor(I iface, java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
super(iface, getProcessMap(processMap));
}

private static <I extends Iface> java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> getProcessMap(java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
processMap.put("helloString", new helloString());
return processMap;
}

public static class helloString<I extends Iface> extends org.apache.thrift.ProcessFunction<I, helloString_args> {
public helloString() {
super("helloString");
}

public helloString_args getEmptyArgsInstance() {
return new helloString_args();
}

protected boolean isOneway() {
return false;
}

@Override
protected boolean handleRuntimeExceptions() {
return false;
}

public helloString_result getResult(I iface, helloString_args args) throws org.apache.thrift.TException {
helloString_result result = new helloString_result();
result.success = iface.helloString(args.what);
return result;
}
}

}

  • 1.先看构造函数
    1
    2
    3
    4
    5
    6
    7
    8
    protected Processor(I iface, java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
    super(iface, getProcessMap(processMap));
    }

    private static <I extends Iface> java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> getProcessMap(java.util.Map<String, org.apache.thrift.ProcessFunction<I, ? extends org.apache.thrift.TBase>> processMap) {
    processMap.put("helloString", new helloString());
    return processMap;
    }

这段把methodName与对应的处理类映射,那后面的事就简单了,当接受到消息,取得methodName,通过map获取对就的处理类回调就可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static class helloString<I extends Iface> extends org.apache.thrift.ProcessFunction<I, helloString_args> {
public helloString() {
super("helloString");
}

public helloString_args getEmptyArgsInstance() {
return new helloString_args();
}

protected boolean isOneway() {
return false;
}

@Override
protected boolean handleRuntimeExceptions() {
return false;
}

public helloString_result getResult(I iface, helloString_args args) throws org.apache.thrift.TException {
helloString_result result = new helloString_result();
result.success = iface.helloString(args.what);
return result;
}
}

处理类,继承ProcessFunction类,实现getResult(),这个方法就是调用了对应service.helloString()

可以再深入看一下,在socket监听消息时

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
client = serverTransport_.accept();
if (client != null) {
processor = processorFactory_.getProcessor(client);
inputTransport = inputTransportFactory_.getTransport(client);
outputTransport = outputTransportFactory_.getTransport(client);
inputProtocol = inputProtocolFactory_.getProtocol(inputTransport);
outputProtocol = outputProtocolFactory_.getProtocol(outputTransport);
if (eventHandler_ != null) {
connectionContext = eventHandler_.createContext(inputProtocol, outputProtocol);
}
while (true) {
if (eventHandler_ != null) {
eventHandler_.processContext(connectionContext, inputTransport, outputTransport);
}
if(!processor.process(inputProtocol, outputProtocol)) {
break;
}
}

关键行:processor.process(inputProtocol, outputProtocol)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public boolean process(TProtocol in, TProtocol out) throws TException {
TMessage msg = in.readMessageBegin();
ProcessFunction fn = processMap.get(msg.name);
if (fn == null) {
TProtocolUtil.skip(in, TType.STRUCT);
in.readMessageEnd();
TApplicationException x = new TApplicationException(TApplicationException.UNKNOWN_METHOD, "Invalid method name: '"+msg.name+"'");
out.writeMessageBegin(new TMessage(msg.name, TMessageType.EXCEPTION, msg.seqid));
x.write(out);
out.writeMessageEnd();
out.getTransport().flush();
return true;
}
fn.process(msg.seqid, in, out, iface);
return true;
}

这就很明显了,通过methodName从map中取得ProccessFunction,再执行process方法,调用相应service的方法

总结

虽然thrift没有按以往java套路出牌,但最根本的把method发送到远程执行是一致的。可能对于多语言来讲,便于所以语言一致性,的确需要通过生成的stub代码手法来实现RPC

当然thrift并不简单,还有很多的内容需要深挖学习,但至少这个简单示例可以了解跨语言型的RPC,相关IDL,Stub的知识,有清晰认知,而不局限于概念

《软技能》笔记之营销与生产力

发表于 2018-09-05
字数统计: 1.7k 字数 | 阅读时长 ≈ 5 分钟

软技能-代码之外的生存技能

《软技能-代码之外的生存技能》这本书可以算是《原则》的实践指南,作者对每一个建议都是事无巨细地指导方案,虽然此书对任何职业都有指导意义,但由于作者程序员的身份,让此书对程序员的实际指导更具体明确

之前《软技能笔记之职业与学习》写了职业与学习,这一篇写营销与生产力;作者的做法有些真是自己实践的,莫名喜感

营销

酒香也怕巷子深,尤其追求实干的程序员是得掌握一些自我营销知识

营销

营销就是一场争夺人们注意力的竞赛

凡人听到营销都会皱眉头,名声实在是不怎么样。
但实事上营销追求的是“实现价值在先,要求回报在后”

价值在先

自我营销的正确方式就是为他人提供价值,学习如何控制好自己要传达的信息,塑造好自己的形象,扩展信息送达的人群

营销并不能确保你一定成功,但是它却是你可控的重要元素

基本机制:要想让人们追随你、倾听你、你就要带给他们价值:为他们的问题提供答案,甚至是给他们带去欢乐

正视自我

我不是专家,没什么可营销的 ———— 其实很多人都喜欢向只比自己稍微优秀一点点的人学习,因为这些人才是可望而又可及的

一直谈论自己并试图证明自己价值连城。然而,你会发现,能解决他人的问题,真正能够帮到他人,你更容易获得成功。

营销手段

  1. 创建品牌:品牌即承诺,承诺按照你预期的方式交付你所预期的价值
  2. 打造博客:最大秘诀有且仅有一个————持之以恒,持之以恒地坚持写作,坚持不懈地产生高品质的内容。感觉没什么可写,提前头脑风暴出各种不同的想法,每当有新想法时,添加到主题列表中
  3. 演讲
  4. 著书立说

思维障碍

看起来像个傻瓜
这种想法,我也有,比如我不太敢主动分享自己的文章,感觉写得傻,蠢。好在我没有不行动,还在坚持写

在我的职业生涯中,我一共错失了9000多次投篮,输掉了近300场比赛。我本来有26次绝杀的机会却投球不进。我失败了一次又一次。 这就是我能够成功的原因 ————迈克尔 乔丹

生产力

这是一个快速变化的世界,时间飞逝,人难免焦虑,很多人会去看很多关于时间管理方面的书籍,但时间真的能管理吗?

如何让自己成为一台性能卓越、品质出众的超级高效机器

这一章节,有些地方给了新的认知,有些我自己也在践行,效果不错,值得一试

制定计划

这不用再重复,没有方向的油轮,是永远到达不了目的地的

可以从季度计划、月计划、周计划、日计划,让自己知道在前进,在向目标靠近

番茄工作法

我没有实践过这个方式,但对于程序员来讲,看一段源码,分析一个bug,几个小时的专注其实没什么问题,有时真是废寝忘食。

所以我对此方法也不屑,但作者的看法,带给我启发

番茄工作法只有被当作估算和评估工作的工具时,才能发挥它的真正威力

制订任务列表全凭主观臆断,每天能够专注完成的工作量才是最重要的

其实作者的意思就是要对自己的专注能力进行量化。量化是很重要的,大多数都是感觉,比如感觉自己很专注的工作,但真正专注了多久呢?上班8小时,专注了几个小时?一周专注工作几个小时?平均每个月能专注多久,这些是需要量化的。

无论是制订目标,还是改进目标,都需要量化,数据说话。就好比性能优化,不能凭感觉,而得给出具体数据。

我很认同作者的这个提法,所以现在也在尝试看自己一天能专注几个番茄时间

定额工作法

制订一些固定周期内的工作

比如:

  1. 每周跑3次
  2. 每周一篇博客
  3. 每天学习一个算法

实现定额制后,我发现自己的工作成果比以往多了很多。最大好处在于,长期坚持这么做,我就能随着时间的推移度量并标记自己的进度。可以确切知道自己在给定的一段时间内能够完成的工作量。

承诺是“定额工作法”的核心这其实很明显,太多人是只会制订计划,但从不执行,所以这是对自己的一份承诺

可以帮助克服意志力薄弱的问题,通过预先设定好的必须要遵循的过程,消除需要做出决策的部分

为什么这种工作法有效呢?

以缓慢但稳定的节奏工作,要优于快速但缺乏持久和坚持的工作方式

这个方法,其实我就在使用,比如我自己制定每月至少写一篇读书笔记、一篇技术学习

开始很难做到,但会时时提醒自己,要去读书,对学习的技术,要做整理。

这样坚持一段时间后,现在我算是超额完成,现在每月要写四篇文章,两篇关于读书的,两篇关于技术的

这样也正好呼应了营销部分的打造一个好博客,坚持写作,一个月四篇,一年就有48篇。其实看看一年量化,这数字也太少了,但要完成还真不容易

有了对自己的承诺,会强迫自己去兑现

保持激情

是人就会疲惫,新鲜感会消退,产生倦怠

想起李笑来的一句话,任何事,前期都不要用力过猛,越猛后期就越乏力

赛跑比的是谁耐力更长久,而不是看谁冲刺更有力

所以生产力的真正秘诀在于:长期坚持做一些小事


延伸阅读:

《软技能笔记之职业与学习》

《原则》读书笔记

《软技能》笔记之职业与学习

发表于 2018-08-24
字数统计: 3.5k 字数 | 阅读时长 ≈ 11 分钟

之前看的《原则》,有没有感觉作者的原则都对,但实际操作又感觉假大空呢?

软技能-代码之外的生存技能

《软技能-代码之外的生存技能》这本书可以算是《原则》的实践指南,作者对每一个建议都是事无巨细地指导方案,虽然此书对任何职业都有指导意义,但由于作者程序员的身份,让此书对程序员的实际指导更具体明确

呐喊

这是此前github上很火的评论,我称之为程序员的呐喊

程序员呐喊

人人都推崇要活到老,学到老。但当真正身陷这个快速变更的行业里,有时难免出现焦躁,疲惫感

如何应对职业生涯,制定好职业规划,是此行业从业者们必须时时反省的话题

软技能

软技能

作者从七个方面阐述他的思想,这一篇记录下职业与学习两方面

职业

职业

作者阐述了在职业中可能遇到的各种问题,面面俱到

心态

不想当将军的士兵不是好士兵

把自己看成老板,不要安逸于一名职员

把自己当做一个软件企业,把雇主当做企业的一个客户,你应当能够提供某种产品或者服务

目标

《原则》中人生五步中的第一步就是确定目标,大多数人没有目标,但更大的问题是不知道如何确定目标

人际交往

talk is cheap ,show me the code

程序员特殊崇尚这句话,喜欢静静的写代码!但实事上每个职业工作都是与人打交道

code is cheap , show me the answer

面试

最快捷的方式让面试官不见其人先闻其声

让面试官对你怀有好感

学习

教育就是当一个人把在学校所学全部忘光之后剩下的东西 –爱因斯坦

教育的首要目标,并不在于“知”而在于“行” –赫伯特 斯宾塞

三要素

为了能够掌握一门技术,我需要了解以下三个要点

  1. 如何开始 ——要想开始使用自己所学的,我需要掌握哪些基本知识?
  2. 学科范围 ——我现在学的东西有多宏大?我应该怎么做?在开始阶段,我不需要了解每个细节,但是如果我能对该学
    科的轮廓有大致的了解,那么将来我就能发现更多细节。
  3. 基础知识 ——不止在开始阶段,要想使用一项特定的技术,我需要了解基本的用户案例和最常见的问题,也需要知道
    自己学的哪20%就能满足80%的日常应用

学习十步法

学习十步法

从第一步到第六步:这些步骤只用作一次!

第一步:了解全局

通常完成这一步我们可以使用网络搜索来完成大量的研究。在这一阶段我们只需要对要学习的东西有一个大概的了解即可。

第二步 :确定范围

现在我们在对我们要学习的东西有一定的了解的情况下,接下来就要集中精力去明确自己到底想要学习什么?在任何项目中,明确项目范围是至关重要的,唯有这样才能了解项目的全局,做好相应的准备工作。

而在这样的一个过程中,我们都很容易犯一个错误就是试图解决太大的问题而把自己搞得不堪重负,因此,我们要明确自己的学习范围。为此我们需要运用在第一步中所获得信息来让自己的关注点落脚在更小也可控制的范围内。

但在此过程中,我们可能会受到诱惑,为了学习该主题下不同的主题,我们可能会扩张自己的学习范围导致自己不够聚焦,所以请务必的抵制这个有诱惑,尽可能的保持专注,一次只能学习一样东西。我们可以稍后再回头学习别的领域的分支。

最后,请一定注意:明确学习范围的时候要考虑的时间因素,你的学习范围务必大小适当,既能够符合你的学习理由,又能符合你的时间限制。

第三步:定义目标

在我们全力以赴之前,明确“成功”的含义极为重要。如果不知道成功是什么样子,很难找准目标,也很难知道自己什么时候已经真正达到目标。所以在当你知道自己的目标是什么的时候,你就可以更轻松的使用倒推方式,明确实现目标所需要的步骤。

这一步的目标是形成一份简明清晰的陈述,勾勒出你勤奋学习后的成功图景。但是一定要确保其中包含的的具体成功标准,从而能让你用来充分评估自己是否已经达成学习目标。

好的目标应该是具体的,无二义性的,不要对自己想要完成的任务进行含糊不清的描述。

你想从自己的学习经历中获取什么决定了你的成功标准是什么。请确保你能借此在学习结束后评估自己是否达成目标。好的成功标准也能让你向着既定目标不断前进。

第四步:寻找资源

要尝试收集到多种多样的资源来帮助你学习,而不是只读一本关于这一主题的书。资源是多种多样的,不局限于书籍。现在随着网络的广泛应用,你几乎可以针对自己感兴趣的人和主题找到大量的资源。

在这一不中,你要尽可能多的寻找自己所选择的相关资料,而且此时你无需考虑这些资源的质量。在你寻找过后,你要对你找到的这些资源进行过滤,去伪存真。

如果你不想因为单一来源的信息而产生偏见,那你就尽可能的去获取各种各样的信息吧。

第五步:创建学习计划

好的技术书都遵循着这样的规律:打好基础,做好铺垫,然后逐个展开每一章的论述。对于大多数学科而言,学习是一个自然的过程。从A开始,前进到B,然后到达Z。这个顺序对你掌握随机的碎片化知识价值不大。你需要找出在最短时间内从A到Z的正确路径,并且到达沿途的重要地标。

在这一步,你需要创建自己的学习路径。把它看作自己写作时候打大纲。

打造自己的学习计划,一个好方法就是借鉴吸取他人的方法,我们这时候可以翻看自己在第四步找来的资料,看看他们是如何学习这个主题的,如果很多不同的作者都把内容分解为相同的模块和顺序,你不妨可以去试一试,效仿他们去做一个自己的学习计划。

第六步:筛选资源

现在,我们知道自己要学习什么,按照什么样的方式去学习,那么是时候决定要使用哪些资源来完成自己的学习任务。现在时候对这些资源进行筛选,挑选最有价值记的几项来帮至自己实现目标。

在这一步中,把我们在第四步中收集的全部资料浏览一遍,找出哪些内容能够覆盖自己的学习计划。

一旦完成了这一步,我们就可以准备进到学习计划中的第一个模块了!

但在我们实现自己的目标之前,我们还需要为每个模块重复第7步到第10步。

第七步到第十步:循环往复(学习——实践——掌握——教授)

第七步: 开始学习,浅尝辄止

在这一步中,我们的目标是获得足够多的与所学主题相关的信息,从让能让我们开始学习,并在下一步中动手操作。

这一步的关键在于过犹不及。我们通常会很容易的就失去自控力,开始消化计划学习中列出的所有资源。但是你会发现,如果你能经受住这样的诱惑,你会取得更大的成就。你要专注于掌握自己所需的、能再下一步动手操作的的最小量的知识。

第八步:动手操作,边学边玩

这一步既有趣又可怕。说它有趣是因为你真的是在玩耍,说它可怕是因为这一步完全没有边际。在一部没有任何规则,你可以做任何你想做的事情,如何更好地实施这一步,完全由你来决定。

大多数人会尝试通过读书或者观看视频来掌握某个主题,他们会提前吸收很多信息,然后再付诸实践。这一方法的问题在于,在他们读书或者看视频的时候,他们并不知道哪些内容是重点。他们只是在因循他们设计好的学习路径。

现在,我们无需提前了解全部内容,你要做的首要的一件事情就是亲自操作和亲身体验。采用这种方法,你通过探索和时间学习。在操作的过程中,你的大脑自然地产生各种各样的问题:它是如何工作的?如果我这么做,它会发生什么?我该如何解决这个问题?这些问题引导着你走向真正重要的方向。当你回过头来寻找问题答案的时候,不只是这些问题迎刃而解,而且你记得的东西比你学习的东西要多得多,因为你所学到的都是对你很重要的东西。

在这一步中,不要担心结果,勇敢探索吧。

第九步:全面掌握,学以致用

好奇心是学习特别是自学的重要组成部分。这一步的目标就是让你找会好奇寻驱动的学习。在第八步中,你通过动手操作发现了一些尚未找到的答案的问题。现在时候来回答这些问题了,在这一步中,你要利用先前收集到的所有资料,进行深入学习。

为了有效利用自己选择的材料,为上一步产生的问题寻找答案,阅读文字、观看视频、与他们交流都是必要的手段。这能让你沉浸在学习材料中,尽可能地汲取知识。

不要害怕回头再去操作,付出更多,因为这不仅能让你找到问题的答案,也能让你学到新的东西。给自己足够多的时间去深入理解自己的主题,你可以阅读,可以实验,可以观察,也可以操作。

不过请记住,你依然没有必要把收集到的所有资料全部仔细看看一遍。你只需要阅读或者观看与当前所学有关的部分。

最后请不要忘了,你在第三步中定义的成功标准。试着把自己正在学习的内容与最终目标关联起来。你掌握的每个模块,都应该以某种方式推动你向着终极目标前行。

第十步:乐为人师,融会贯通

如果你真的想深入地掌握一门学问,想对这门学问做到融会贯通,那么你必须要做到”好为人师“。除此之外,别无他法。

在现实中,你只需要超前别人一步,就可以成为他们的老师。有时候,比学生超前太多的专家反而不能得心应手的教,因为他们无法与学生产生共鸣。他们忘记了初学者是什么样子,很容易专注于他们认为简单的细节。

在这一步中,我们要要求自己走出自己的舒适区,将自己学到的知识教给别人。要想确定你确实掌握了某些知识,这是唯一的办法;同时在我们将自己所学到的东西介绍给别人时,这也是查缺补漏的好办法。

你可以通过很多方式将自己所学交给他们。你可以写博客,也可以制作视频。你也可以跟自己的舍友,基友,爱人以及朋友探讨,将自己所学解释给他们。

重点在于,你要花时间将自己学到的东西从大脑提取出来,以别人能理解的方式组织起来。在经历这整个过程之后,你会发现,有很多你以为自己明白了的知识点,其实并没有摸透。于是你将会将那些以前自己没太明白的东西联系起来,并且简化自己大脑中已有的信息,将它们浓缩并经常复习。

后续

这篇只记录了职业与学习两部分,后续还有程序员更需要关注的营销部分

百万QPS系统的缓存实践

发表于 2018-08-11
字数统计: 1.9k 字数 | 阅读时长 ≈ 7 分钟

标题有些吸引眼球了,但并不浮夸,甚至还会远远超过百万,现在的平均响应时间在1ms内,0.08ms左右

如此高的QPS,如此低的AVG,为什么会有如此效果,关键点还是在多级缓存上

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流

概述

查询过程

上图基本上就是查询的通用方案,缓存中是否存在,存在就返回,不存在再查询Db,查询到的结果load进缓存

实践

缓存,逃不过三种操作,创建、查询、删除

此实践可能不保证全场景通用,但满足当前系统各项指标,当然没有完美的方案,只有适合的方案。

下面的时序图中,cache lv1是指本地缓存,cache lv2是cache cluster

查询

查询

查询过程:

从一级缓存开始查,如果没有,再向下一级查询,直到db

注意点:

  1. 一直查到db时,需要回源各级cache
  2. 防止击穿,需要在cache中填充value

创建

创建

创建过程:

  1. 创建cacheObject
  2. 放入Db(为了性能,以及db的降级,这儿可以引入异步开关)
  3. 放入cache lv2
  4. 放入cache lv1
  5. publish创建成功消息
  6. 消息监听服务会通知其它服务更新本地缓存

注意点:

  1. 到底是先放入Db,还是先放入cache
  2. db与cache的一致性保障

删除

删除

删除过程:

  1. 通过key查询cacheobject
  2. 清除db
  3. 清除各级cache
  4. publish消除成功消息
  5. 监听服务清除其它服务的本地缓存

注意点:

  1. 先清除db还是cache
  2. Db与cache的一致性保障

缓存操作模式

除了创建,查询,删除,还有更新操作;但我们业务场景没有。

对于我们的实践是不是放之四海而皆准,肯定是不行的。不以业务为基础的设计都是无根之木

先看下业界常见的操作缓存模式

更新缓存的的Design Pattern有四种:Cache aside, Read through, Write through, Write behind caching

Cache aside

  • 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后,再让缓存失效。

查询

更新

这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。

那么,是不是Cache Aside这个就不会有并发问题了?不是的,比如,一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后,之前的那个读操作再把老的数据放进去,所以,会造成脏数据。

但,这个case理论上会出现,不过,实际上出现的概率可能非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必须在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率基本并不大。

Cache Aside,我们的应用代码需要维护两个数据存储,一个是缓存(Cache),一个是数据库(Repository)。所以,应用程序比较啰嗦。而Read/Write Through套路是把更新数据库(Repository)的操作由缓存自己代理了,所以,对于应用层来说,就简单很多了。可以理解为,应用认为后端就是一个单一的存储,而存储自己维护自己的Cache。

Read Through

Read Through 套路就是在查询操作中更新缓存,也就是说,当缓存失效的时候(过期或LRU换出),Cache Aside是由调用方负责把数据加载入缓存,而Read Through则用缓存服务自己来加载,从而对应用方是透明的。

这似乎很像guave的LoadCache

Write Through

Write Through 套路和Read Through相仿,不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库(这是一个同步操作)

Write Back

在更新数据的时候,只更新缓存,不更新数据库,而我们的缓存会异步地批量更新数据库。这个设计的好处就是让数据的I/O操作飞快无比(因为直接操作内存嘛 ),因为异步,write backg还可以合并对同一个数据的多次操作,所以性能的提高是相当可观的。

在wikipedia上有一张write back的流程图,基本逻辑如下:

write back

在游戏开发中基本上都是使用这种模式

但他也有缺点:

  1. 数据不是强一致性
  2. 数据可能会丢失
  3. 逻辑比较复杂

争论

  1. 一致性问题
    这儿的一致性是说强一致性,在分布式环境下,保证强一致性促使系统复杂性增加,或者性能有所下降。所以现在一般对非强制性业务场景都使用最终一致性解决。一致性的解读可以看看《zookeeper-paxos》,在我们实践时,在删除操作时,在清理失败时也通过补偿操作去尝试清除。
  2. 到底是update cache,还是delete cache
    其实任务技术手段都是看业务场景的,不能一概而论
    • update cache
      这个在并发写时,A1写db,B1写db,B2写cache,A2写cache;这时就出现db与Cache不一致的问题
      主动更新缓存,如果cacheobject复杂,需要Db与cache的多次交互,虽然减少了一次cache miss,但却增加了系统复杂度,得不偿失
    • delete cache
      这个不会有不一致问题了,但会造成cache miss,会不会造成热key穿透?
  3. 是先操作Db,还是cache
    假设先操作cache,再操作db;A B并发操作,A1 delete cache; B1 get cache –> miss –> select db –> load cache;A2 delete db;
    此种情况就出现此key一直有效状态,如果没有设置超时时间,那会长期在缓存中。这是不是得先操作db呢?
    一个操作先update db,再delte cache时失败了;那会数据库里是新数据,而缓存里是旧数据,业务无法接受。那是不是该先操作缓存呢?

是不是已经晕头了呢?

再有db主从架构中,主从不一致的情况,是不是没法玩了

所以还是开篇讲的没有放之四海而皆准的方案,只能寻找最适合的方案

在各种业务场景下,还是需要去寻找一些最佳实践,比如关注一下缓存过期策略、设置缓存过期时间

参考资料

缓存更新的套路

A beginner’s guide to Cache synchronization strategies

仅且仅创建一次对象

发表于 2018-07-18
字数统计: 1.3k 字数 | 阅读时长 ≈ 5 分钟

此篇算是对《voliatile,synchronized,cas》理论的一种实践

全局引用场景

单例模式

不用讲,这是首先想到的方式。

饿汉式 static final field

1
2
3
4
5
6
7
8
9
public class Singleton{
//类加载时就初始化
private static final Singleton instance = new Singleton();

private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}

这是最简单又安全的方式。但也有缺点:

  1. 它不是一种懒加载模式(lazy initialization)
  2. 一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

静态内部类

1
2
3
4
5
6
7
8
9
public class Singleton {  
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}

这种写法仍然使用JVM本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本

双重检验锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
private volatile static Singleton instance; //声明成 volatile
private Singleton (){}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}

}

这个写法得注意到volatile

主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。

  1. 给 instance 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
    但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。

声明为volatile,使用其一个特性:禁止指令重排序优化。

也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。

volatile的更多特性,可以看一下上篇文章《voliatile,synchronized,cas》

间接被引用情景

需要创建一次的对象不是直接被全局的引用所引用,而是间接地被引用。经常有这种情况,全局维护一个并发的ConcurrentMap, Map的每个Key对应一个对象,这个对象需要只创建一次

CAS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private final ConcurrentMap<String, InstanceObject> cache
= new ConcurrentHashMap<>();

public InstanceObject get(String key) {
InstanceObject single = cache.get(key);
if (single == null) {
InstanceObject instanceObject = new InstanceObject(key);
single = cache.putIfAbsent(key, instanceObject);
if (single == null) {
single = instanceObject;
}
}
return single;
}

使用这个很可能会产生多个InstanceObject对象,但最终只有一个InstanceObject有用

但并不没有达到仅创建一个的目标

如果创建InstanceObject的成本不高,那也不用太讲究

但一旦是大对象缓存,那么这很可能就是问题了,因为缓存中的对象获取成本一般都比较高,而且通常缓存都会经常失效,那么避免重复创建对象就有价值了

影子类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private final ConcurrentMap<String, Future<InstanceObject>> cache1 = new ConcurrentHashMap<>();

public InstanceObject get1(final String key) {
Future<InstanceObject> future = cache1.get(key);
if (future == null) {
Callable<InstanceObject> callable = new Callable() {
@Override
public InstanceObject call() throws Exception {
return new InstanceObject(key);
}
};
FutureTask<InstanceObject> task = new FutureTask<>(callable);

future = cache1.putIfAbsent(key, task);
if (future == null) {
future = task;
task.run();
}
}

try {
return future.get();
} catch (Exception e) {
cache.remove(key);
throw new RuntimeException(e);
}
}

这儿使用Future来代替真实的对象,多次创建Future代价比创建缓存大对象小得多

自旋锁

觉得Future对象还是重了,那就使用更轻的AtomicBoolean,那其实主要使用的还是volatile的特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final ConcurrentMap<String, AtomicBoolean> spinCache = new ConcurrentHashMap<>();

public InstanceObject getAtomic(final String key) {
InstanceObject single = cache.get(key);
if (single == null) {
AtomicBoolean newBoolean = new AtomicBoolean(false);
AtomicBoolean oldBoolean = spinCache.putIfAbsent(key, newBoolean);
if (oldBoolean == null) {
cache.put(key, new InstanceObject(key));
newBoolean.set(true);
} else {
//其他线程在自旋状态上自旋,等等被释放
while (!oldBoolean.get()) {}
}
single = cache.get(key);
}
return single;
}

总结

保守写法可以使用synchronized,lock,他们的性能也不低;但为了性能极致,可以使用上面的方式。

完整的测试代码:https://github.com/zhuxingsheng/javastudy/blob/master/src/main/java/com/jack/createonlyone/CreateOnlyOneMain.java

主动GC是否需要

发表于 2018-07-13 | 分类于 java
字数统计: 1.7k 字数 | 阅读时长 ≈ 7 分钟

看一段线上的gc日志,这是一段CMS完整步骤的日志,对于GC日志格式,不了解的可以再温习一下《GC及JVM参数》

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2017-07-18T21:28:41.422+0800: 11941915.242: [GC (CMS Initial Mark) [1 CMS-initial-mark: 786446K(1048576K)] 789098K(1992320K), 0.2623622 secs] [Times: user=0.00 sys=0.00, real=0.26 secs] 
2017-07-18T21:28:41.684+0800: 11941915.505: Total time for which application threads were stopped: 0.2630097 seconds, Stopping threads took: 0.0000587 seconds
2017-07-18T21:28:41.685+0800: 11941915.505: [CMS-concurrent-mark-start]
2017-07-18T21:29:02.443+0800: 11941936.263: [CMS-concurrent-mark: 20.758/20.758 secs] [Times: user=3.22 sys=1.67, real=20.75 secs]
2017-07-18T21:29:02.443+0800: 11941936.263: [CMS-concurrent-preclean-start]
2017-07-18T21:29:02.502+0800: 11941936.322: [CMS-concurrent-preclean: 0.059/0.059 secs] [Times: user=0.02 sys=0.01, real=0.06 secs]
2017-07-18T21:29:02.502+0800: 11941936.322: [CMS-concurrent-abortable-preclean-start]
CMS: abort preclean due to time 2017-07-18T21:29:07.600+0800: 11941941.420: [CMS-concurrent-abortable-preclean: 0.889/5.098 secs] [Times: user=1.72 sys=0.31, real=5.10 secs]
2017-07-18T21:29:07.602+0800: 11941941.422: Application time: 25.9175914 seconds
2017-07-18T21:29:07.603+0800: 11941941.423: [GC (CMS Final Remark) [YG occupancy: 491182 K (943744 K)]
2017-07-18T21:29:07.603+0800: 11941941.423: [Rescan (parallel) , 0.0654053 secs]
2017-07-18T21:29:07.668+0800: 11941941.488: [weak refs processing, 0.6491578 secs]
2017-07-18T21:29:08.317+0800: 11941942.138: [class unloading, 4.2229435 secs]
2017-07-18T21:29:12.540+0800: 11941946.361: [scrub symbol table, 0.0536739 secs]
2017-07-18T21:29:12.594+0800: 11941946.414: [scrub string table, 0.0009992 secs][1 CMS-remark: 786446K(1048576K)] 1277629K(1992320K), 5.0003976 secs] [Times: user=0.96 sys=0.01, real=5.00 secs]
2017-07-18T21:29:12.603+0800: 11941946.423: Total time for which application threads were stopped: 5.0011973 seconds, Stopping threads took: 0.0000483 seconds

对应着七个步骤:

  1. 初始标记(CMS-initial-mark) ,会导致swt;
  2. 并发标记(CMS-concurrent-mark),与用户线程同时运行;
  3. 预清理(CMS-concurrent-preclean),与用户线程同时运行;
  4. 可被终止的预清理(CMS-concurrent-abortable-preclean) 与用户线程同时运行;
  5. 重新标记(CMS-remark) ,会导致swt;
  6. 并发清除(CMS-concurrent-sweep),与用户线程同时运行;
  7. 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;

通过

1
[Times: user=0.96 sys=0.01, real=5.00 secs]

看出STW了5s,对于一个单台1万QPS的系统来讲,那5s就影响了上万次服务,这显示然达不到高可用的要求

通过对user,sys,real的对比,user+sys的时间远远小于real的值,这种情况说明停顿的时间并不是消耗在cpu执行上了,不是cpu那就是io导致的了

此时,可以通过sar命令查看一下

sar(System Activity Reporter系统活动情况报告)是目前 Linux 上最为全面的系统性能分析工具之一,可以从多方面对系统的活动进行报告,包括:文件的读写情况、系统调用的使用情况、磁盘I/O、CPU效率、内存使用状况、进程活动及IPC有关的活动等

sar -B

sar -B 输出说明:

输出项说明:

pgpgin/s:表示每秒从磁盘或SWAP置换到内存的字节数(KB)

pgpgout/s:表示每秒从内存置换到磁盘或SWAP的字节数(KB)

fault/s:每秒钟系统产生的缺页数,即主缺页与次缺页之和(major + minor)

majflt/s:每秒钟产生的主缺页数.

pgfree/s:每秒被放入空闲队列中的页个数

pgscank/s:每秒被kswapd扫描的页个数

pgscand/s:每秒直接被扫描的页个数

pgsteal/s:每秒钟从cache中被清除来满足内存需要的页个数

%vmeff:每秒清除的页(pgsteal)占总扫描页(pgscank+pgscand)的百分比

SWAP

可以看到大量的pgin,这儿就不得不再普及一下linux的swap

Linux divides its physical RAM (random access memory) into chucks of memory called pages. Swapping is the process whereby a page of memory is copied to the preconfigured space on the hard disk, called swap space, to free up that page of memory. The combined sizes of the physical memory and the swap space is the amount of virtual memory available.

Swap space in Linux is used when the amount of physical memory (RAM) is full. If the system needs more memory resources and the RAM is full, inactive pages in memory are moved to the swap space. While swap space can help machines with a small amount of RAM, it should not be considered a replacement for more RAM. Swap space is located on hard drives, which have a slower access time than physical memory.Swap space can be a dedicated swap partition (recommended), a swap file, or a combination of swap partitions and swap files.

Linux内核为了提高读写效率与速度,会将文件在内存中进行缓存,这部分内存就是Cache Memory(缓存内存)。即使你的程序运行结束后,Cache Memory也不会自动释放。这就会导致你在Linux系统中程序频繁读写文件后,你会发现可用物理内存变少。当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间被临时保存到Swap空间中,等到那些程序要运行时,再从Swap分区中恢复保存的数据到内存中。这样,系统总是在物理内存不够时,才进行Swap交换。

swap vs 虚拟内存

windows:虚拟内存

linux:swap分区

windows即使物理内存没有用完也会去用到虚拟内存,而Linux不一样 Linux只有当物理内存用完的时候才会去动用虚拟内存(即swap分区)

swap类似于windows的虚拟内存,不同之处在于,Windows可以设置在windows的任何盘符下面,默认是在C盘,可以和系统文件放在一个分区里。而linux则是独立占用一个分区,方便由于内存需求不够的情况下,把一部分内容放在swap分区里,待内存有空余的情况下再继续执行,也称之为交换分区,交换空间是其中的部分
windows的虚拟内存是电脑自动设置的

为什么会停顿这么长时间呢?

  1. 堆内存分配多大,当gc时,的确需要很长时间
  2. 内存不够用时,使用了swap,在gc时,需要从swap加载到内存,耗时

解决思路

对于上面的原因,可以找出对应的方案:

  1. 分配小点,通过小而快的方式达到快速gc
  2. 定期检测old gen使用情况,当快要到达临界值时候(old gen使用率大于50%)主动执行cms gc

主动Gc可能会影响服务,所以可能需要服务先下线,gc完,再上线

参考资料

CMS垃圾回收器详解

GC Algorithms: Implementations

volatile synchronized cas

发表于 2018-06-26 | 分类于 java
字数统计: 7.7k 字数 | 阅读时长 ≈ 28 分钟

之前写了《熔断》,以及其中使用的《计数器算法》;本来是要接着再写不通过定时器清理计数环的计数器算法,看了下我司亿级网关的计数器,百行的代码,但却是满满bug。不得穿插一下并发的基础知识

处理并发,最基本的元件就这三样

  1. synchronized 这个关键字不必讲,从开始多线程,它就进入你的视线
  2. volatile 在jdk5之后大放异彩
  3. cas 在J.U.C中大量使用,他与volatile组合是J.U.C的基石

JMM

谈到多线程,不得不说的JMM,这儿只做简单阐述

在jsr-133中是这么定义的

A memory model describes, given a program and an execution trace of that program,
whether the execution trace is a legal execution of the program. For the Java programming language,
the memory model works by examining each read in an execution trace and checking that the write observed by that read is valid according to certain rules.

也就是说一个内存模型描述了一个给定的程序和和它的执行路径是否一个合法的执行路径。对于java序言来说,内存模型通过考察在程序执行路径中每一个读操作,根据特定的规则,检查写操作对应的读操作是否能是有效的。
java内存模型只是定义了一个规范,具体的实现可以是根据实际情况自由实现的。但是实现要满足java内存模型定义的规范。

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

volatile

由于java的内存模型中有工作内存和主内存之分,所以可能会有两种问题:

(1)线程可能在工作内存中更改变量的值,而没有及时写回到主内存,其他线程从主内存读取的数据仍然是老数据

(2)线程在工作内存中更改了变量的值,写回主内存了,但是其他线程之前也读取了这个变量的值,这样其他线程的工作内存中,此变量的值没有被及时更新。

为了解决这个问题,可以使用同步机制,也可以把变量声明为volatile,

JMM:对volatile字段的写入操作happens-before于每一个后续的同一个字段的读操作

如何理解呢?

(1)每次对变量的修改,都会引起处理器缓存(工作内存)写回到主内存。

(2)一个工作内存回写到主内存会导致其他线程的处理器缓存(工作内存)无效。

基于以上两点,如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

volatile原子性

Java内存模型要求lock, unlock, read, load, assign, use, write这个8个操作都具有原子性,但是同时又对64位的数据类型(long&double)给了一个相对宽松的规定,就是允许虚拟机将没有被volatile参数修饰的64位数据类型的读写划分为两次32位的操作来进行,即允许虚拟机将load, store, read, write这个4个操作实现为非原子的。

当线程把主存中的long/double类型的值读到线程内存中时,可能是两次32位值的写操作,显而易见,如果几个线程同时操作,那么就可能会出现高低2个32位值出错的情况发生。

java虚拟机规范(jvm spec)中,规定了声明为volatile的long和double变量的get和set操作是原子的

Writes and reads of volatile long and double values are always atomic.
Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values.

关于volatile变量的使用建议:多线程环境下需要共享的变量采用volatile声明;如果使用了同步块或者是常量,则没有必要使用volatile。

当然,需要注意的是,这儿的原子性,与i++不是一个概念

前者是单个变量写,后者是复合操作

volatile实现

volatile是如何做到可见性的呢?

来段代码看下,定义两个变量

1
2
3
private int i;

private volatile int j;

通过java -verbos XX.class 查看一下生成的编译码

发现唯一的区别就在于volatile多了ACC_VOLATILE标识

通过查看JVM源码,可以看到如下代码

这就是大名鼎鼎的“内存屏障”的抽象

内存屏障

内存屏障Memory Barriers:是一组处理器指令,用于实现对内存操作的顺序限制。

Memory barrier 能够让 CPU 或编译器在内存访问上有序。一个 Memory barrier 之前的内存访问操作必定先于其之后的完成。

内存屏障有两个能力:

  1. 阻止屏障两边的指令重排序
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

Memory barrier 分类:

  1. 编译器 barrier
  2. CPU Memory barrier

内存屏障列表:

JMM针对编译器制定的volatile重排序规则表:

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从上表我们可以看出:

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

volatile的内存语义的(JVM)实现策略:

  1. 在每个volatile写操作前,会插入一个StoreStore屏障;
  2. 在每个volatile写操作后,会插入一个storeload屏障;
  3. 在每个volatile读操作后,插入一个LoadLoad,一个LoadStore屏障


上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。

这里比较有意思的是volatile写后面的StoreLoad屏障。这个屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面,是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在这里采取了保守策略:在每个volatile写的后面或在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里我们可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
    从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

上述的1属于编译器重排序,2和3属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

比如现在有一段代码如下:

1
2
a = 1; //代码1
b = 1; //代码2

编译器和处理为了提高并行度,可以将代码1和2调整顺序,即先执行代码2和代码1

但是若是其他情况:

1
2
a = 1; //代码3
b = a; //代码4

这种情况因为代码3和4存在数据依赖,存在hanpens-before关系,处理器和编译器会遵守 as-if-serial原则,不会调整顺序。

as-if-serial原则:不管怎么重排序,单线程程序的执行结果不能发生改变。编译器、Runtime和处理器也是如此。这个语义相当于把单线程保护起来了,所以即使编译器和处理器对指令序列进行了重排序,我们也会认为程序指令并没有发生重排序

hanpens-before:指前一个操作对后一个操作可见,并不是前一个操作必须在后一个操作之前执行。

当存在控制依赖时,编译器和处理器会采取猜测执行机制来提高并行度,如下代码:

1
2
3
4
5
a = 1;
flag = true ;
if(flag){ //代码5
a * = 2; //代码6
}

代码5和6不存在数据依赖,可能会重排,处理器和编译器会先将代码6的执行结果放在缓冲区,等执行代码5之后,将缓冲区的结果直接赋值给a

从JSR-133开始,volatile写-读建立的happens before关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class VolatileExample {
int a = 0;
volatile boolean flag = false;

public void writer() {
a = 1; //1
flag = true; //2
}

public void reader() {
if (flag) { //3
int i = a; //4
……
}
}
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens before规则,这个过程建立的happens before 关系可以分为两类:
· 根据程序次序规则,1 happens before 2; 3 happens before 4。
· 根据volatile规则,2 happens before 3。
· 根据happens before 的传递性规则,1 happens before 4。
上述happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens before保证。

这里A线程写一个volatile变量后,B线程读同一个volatile变量。
A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。

在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。
在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获取具有的内存语义。

为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强

volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。

从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。

volatile示例

还是不太明白,直接跑段代码,区别一下flag带与不带volatile修饰的情况,就很明显了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class TestVolatile {

public static void main(String[] args) {

ThreadDemo td = new ThreadDemo();
new Thread(td).start();

while(true){
if(td.getFlag()){
System.out.println("主线程flag:" + td.getFlag());
break;
}
}
}
}

class ThreadDemo implements Runnable{
//共享变量
private volatile boolean flag = false;

public boolean getFlag() {
return flag;
}

public void setFlag(boolean flag) {
this.flag = flag;
}

@Override
public void run() {

try {
Thread.sleep(200);
} catch (Exception e) {
}

flag = true;

System.out.println("其他线程flag=" + getFlag());
}
}

synchronized

在多线程并发编程中Synchronized一直是元老级角色,很多人都会称呼它为重量级锁

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了“偏向锁”和“轻量级锁”,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

1
2
3
4
5
6
7
8
9
10
11
12
public class SynchronizedTest {

public synchronized void test1(){

}

public void test2(){
synchronized (this){

}
}
}

能过javap -v 查看编译后的代码:

从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

关于这两条指令的作用,我们直接参考JVM规范中描述:

monitorenter

Each object is associated with a monitor. A monitor is locked if and only if it has an owner. The thread that executes monitorenter attempts to gain ownership of the monitor associated with objectref, as follows:
• If the entry count of the monitor associated with objectref is zero, the thread enters the monitor and sets its entry count to one. The thread is then the owner of the monitor.
• If the thread already owns the monitor associated with objectref, it reenters the monitor, incrementing its entry count.
• If another thread already owns the monitor associated with objectref, the thread blocks until the monitor’s entry count is zero, then tries again to gain ownership.

这段话的大概意思为:

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:

The thread that executes monitorexit must be the owner of the monitor associated with the instance referenced by objectref.
The thread decrements the entry count of the monitor associated with objectref. If as a result the value of the entry count is zero, the thread exits the monitor and is no longer its owner. Other threads that are blocking to enter the monitor are allowed to attempt to do so.

这段话的大概意思为:

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
a = 1;
flag = true;
}

public synchronized void reader() {
if (flag) {
int i = a;
……
}
}
}

上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。

cas

Java在JDK1.5之前都是靠 synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有共享变量的锁,都采用独占的方式来访问这些变量。这就是一种独占锁,独占锁其实就是一种悲观锁,所以可以说 synchronized 是悲观锁。

悲观锁机制存在以下问题:  

  1. 在多线程竞争下,加锁、释放锁会导致比较多的上下文切换和调度延时,引起性能问题。
  2. 一个线程持有锁会导致其它所有需要此锁的线程挂起。
  3. 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能风险。

对比于悲观锁的这些问题,另一个更加有效的锁就是乐观锁。

其实乐观锁就是:每次不加锁而是假设没有并发冲突而去完成某项操作,如果因为并发冲突失败就重试,直到成功为止。

CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。
(在 CAS 的一些特殊情况下将仅返回 CAS 是否成功,而不提取当前值。)
CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;
否则,不要更改该位置,只告诉我这个位置现在的值即可。”这其实和乐观锁的冲突检查+数据更新的原理是一样的。

1
2
3
public final boolean compareAndSet(int expect, int update) {   
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
unsafe.compareAndSwapInt(this, valueOffset, expect, update);

类似:

if (this == expect) {

this = update

return true;

} else {

return false;

}

CAS原理

CAS通过调用JNI的代码实现的。JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。

而compareAndSwapInt就是借助C来调用CPU底层指令实现的。

下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:

1
2
3
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);

可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intel x86处理器的源代码的片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}

如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。

intel的手册对lock前缀的说明如下:

  1. 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

CAS缺点

CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作

  1. ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A
    从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
  2. 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
  3. 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁,或者有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。

concurrent包的实现

由于java的CAS同时具有 volatile 读和volatile写的内存语义,因此Java线程之间的通信现在有了下面四种方式:

  1. A线程写volatile变量,随后B线程读这个volatile变量。
  2. A线程写volatile变量,随后B线程用CAS更新这个volatile变量。
  3. A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量。
  4. A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量。

Java的CAS会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键(从本质上来说,能够支持原子性读-改-写指令的计算机器,是顺序计算图灵机的异步等价机器,因此任何现代的多处理器都会去支持某种能对内存执行原子性读-改-写操作的原子指令)。同时,volatile变量的读/写和CAS可以实现线程之间的通信。把这些特性整合在一起,就形成了整个concurrent包得以实现的基石。如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式:

  • 首先,声明共享变量为volatile;
  • 然后,使用CAS的原子条件更新来实现线程之间的同步;
  • 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。

volatile vs synchronized

1.volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
2.volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的。
3.volatile仅能实现变量的修改可见性,不能保证原子性(线程A修改了变量还没结束时,另外的线程B可以看到已修改的值,而且可以修改这个变量,而不用等待A释放锁,因为Volatile 变量没上锁);而synchronized则可以保证变量的修改可见性和原子性。
4.volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞和上下文切换。
5.volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。

6.在使用volatile关键字时要慎重,并不是只要简单类型变量使用volatile修饰,对这个变量的所有操作都是原子操作。当变量的值由自身决定时,如n=n+1、n++ 等,volatile关键字将失效。只有当变量的值和自身无关时对该变量的操作才是原子级别的,如n = m + 1,这个就是原级别的。所以在使用volatile关键时一定要谨慎,如果自己没有把握,可以使用synchronized来代替volatile。

7.“锁是昂贵的”,谨慎使用锁机制。

参考资料

Java Language Specification

volatile的底层源码分析

volatile

内存屏障(Memory barrier)

深入理解Java内存模型(四)——volatile

1…10111213
朱兴生

朱兴生

128 日志
3 分类
50 标签
© 2016 — 2022 朱兴生 | Site words total count: 305.9k
由 Hexo 强力驱动
|
主题 — NexT.Mist v5.1.4
沪ICP备18040647号-1