Java 基础学习(一)

作者: 一字马胡

谈谈java类加载机制,什么是双亲委派模型?优缺点是什么?

1、从java虚拟机的角度来看,有两种类型的类加载器,分别是启动类加载器(Bootstrap ClassLoader)和其他类加载器,启动类加载器由JVM底层实现,其他类加载器均继承自java.lang.ClassLoader类。
从开发者的角度,类加载器可以分为:

  • 启动(Bootstrap)类加载器:负责将 JAVA_HOME/jre/lib/下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

  • 标准扩展(Extension)类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将JAVA_HOME/jre/lib/ext/或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。

  • 应用程序(Application)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,因此一般称为系统(System)加载器。

2、双亲委派模型:双亲委派模型用于描述java类加载机制,当一个类加载器在收到一个加载类的请求的时候,首先将交给自己的父类加载器来尝试类的加载,如果父类加载器可以完成类的加载,那么类加载成功,否则,父类加载器继续交由其父类加载器来完成类加载操作,直到没有父类加载器为止,如果父类加载器都不能完成类加载的请求,那么最先收到请求的类加载器才会尝试去加载类。“双亲委派”机制只是Java推荐的机制,并不是强制的机制。我们可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。

双亲委派模型

3、双亲委派模型的优点:

  • 可以防止jvm中出现多份相同的字节码(防止重复加载)
  • 保证类的安全,比如你无法加载一个自定义的java.lang.System类,因为这个类存在于JAVA_HOME/jre/lib/rt.jar下,根据双亲委派模型,最终加载java.lang.System类的类加载器为启动类加载器,所以非JAVA_HOME/jre/lib/rt.jar下的java.lang.System类是不会被加载的。除此之外,可能会存在另外一个风险,java.lang.System是存在了,但是java.lang.Viruses不存在呀,假设这个类被成功加载,那么是否这个java.lang.Viruses类会去恶意破坏java.lang包下面的类呢?答案是不会,因为java.lang.Viruses和合法的java.lang包的加载使用的不是同一个类加载器,所以它们不属于同一个运行时包,因而java.lang.Viruses是没有权限去访问java.lang下的包的。

4、双亲委派模型的缺点

  • 参考JDBC、Spring、Tomcat类加载的设计

如何实现单例模式?

实现单例模式需要关注的点有两个:
(1)、类的实例只有一个
(2)、线程安全

围绕上面两个问题,可以有下面几种实现单例的方法:

  • DCL(double checking locking),这种称为双重检测锁定的技术可以解决多线程环境下的线程安全问题,至于为什么需要使用volatile关键字来修饰实例对象,究其原因是因为指令重排序问题。编译器在为了达到最优化效果,会对指令进行一些重排序操作,比如,对于new一个对象这个操作,需要经过下面三个过程:
    • (a) 申请足够大的一块内存
    • (b)初始化申请好的这块内存空间
    • (c)将新对象指向这块内容

因为(b)和(c)之间并没有相互依赖的关系,所以编译器可能会将(b)和(c)的执行顺序调整过来,这个场景下,假设有两个线程A和B,A获得了同步锁,然后执行了new操作,但是执行到(c)之后,B开始执行第一个check,此时因为线程A执行了(c)操作,此时类的实例已经不为null了,那么线程B以为单例已经实例化好了,这样,如果线程B立刻对对象进行操作的话,就会出现问题。使用volatile关键字,是的new的指令时严格按照(a)-(b)-(c)的顺序执行的,不会出现上面描述的情况,代码如下:

public class SingletonClass {
   private static volatile SingletonClass _instance = null;
   private SingletonClass() {}
   public static SingletonClass getInstance() {
        if (_instance == null) {
             synchronized(SingletonClass.class) {
             if (_instance == null) {
                   _instance = new SingletonClass();
              }
           }
        }
   }
} 
  • 除了使用DCL技术之外,还可以使用类初始化特性来实现安全正确的单例模式。JVM在进行类初始化时,JVM会去获取一个锁,可以防止多个线程对同一个类进行初始化。实现代码如下:
private class SingletonClass {
   private static SingletonClass InstanceHolder {
          public static final SingletonClass DEFAULT = new SingletonClass();
   }
    private SingletonClass() {}
    public static SingletonClass getInstance() {
        return  InstanceHolder.DEFAULT;
    }
}

假设有两个线程同时需要初始化类SingletonClass,那么都需要首先获取一个锁,获取锁成功的线程可以进行初始化工作,没有获取到的线程只能等待,而同一个线程内new的指令重排序是不影响最后结果的。

ConcurrentHashMap的remove操作是否会改变table的某个index上的数据结构?如果会,那么具体是怎么变化的?

  • 首先,删除一个节点只会使table里面的记录数最多减少一个,并且不会增加,所以,如果table的某个index上的数据结构发生变化,只可能是红黑树变为链表,而不会反过来。
  • 其次,考虑是否需要将红黑树变为链表,也就是是否有必要在删除记录之后调整存储记录的数据结构,这就涉及红黑树这种数据结构的具体原理细节了。根据我的理解,数据结构的调整看起来不是那么必须的,因为在删除了节点之后满足将红黑树调整为链表的要求之后,数据结构调整,但是后续可能有很多新增的记录落到该index上,那么到时候还是需要将链表调整为红黑树的,这样可能会造成反复调整数据结构的情况,但是在jdk 1.8的实现上,确实在删除了节点之后可能会调整红黑树为链表的。
  • 最后,既然会将红黑树调整为链表,那么具体的调整策略是什么呢?根据注释:"if now too small, so should be untreeified",策略就是如果删除了一个记录之后,该红黑树上的节点太少,那么就需要进行untreeified操作,也就是将红黑树调整为链表,那么具体的判定为“too small”的细节是什么呢,参考下面这段代码,具体的原因还是得理解了红黑树的原理之后才能明白:

if ((r = root) == null || r.right == null || // too small
      (rl = r.left) == null || rl.left == null)
   return true;

并发环境下如何高效的进行sum操作?

在并发环境下,sum操作的难点在于多个线程可能同时在对计数对象的容器进行读写操作,所以,在进行sum操作的时候势必要考虑多线程下数据一致性的问题,下面是一些解决并发环境下sum操作的一些方案:

  • 【方案一】在进行sum操作之后对对象容器加锁,此时其他线程无法获取到写锁,容器内部的对象不会有增减的现象,可以安全的对容器内部的对象计数,保证数据一致性。
  • 【方案二】使用AtomicLong/AtomicInteger来做计数,AtomicLong/AtomicInteger使用CAS来实现原子操作,可以在并发环境下保证数据一致性。
  • 【方案三】使用jdk 1.8引入的并发计数组件Striped64,该组件结合CAS技术以及竞争分散技术,使用一个base变量和一个Cell数组来实现并发环境下的高效计数工作。

当然,除了上述三种方案之外,还有其他的一些方案来满足需求,暂且来分析一下上面提到的三种方案的优缺点。方案一的优点是实现简单,缺点也很明显,一个是使用了锁,效率是一个明显的短板,并且该方案的另外一个缺点是sum操作不是实时的,也就是sum的结果并非一定能准确的对容器进行计数,原因就是,在进行sum操作之前,对容器进行了加锁,而加锁之后其他队容器进行写的线程将被阻塞等待,而这些写线程明显会对容器的记录大小造成影响,而计数线程没有感知到这些变化,所以最终的计数结果是不准确的。

对于方案二,使用CAS来解决多线程竞争已经是一种思想层面的提高,CAS和锁比起来确实要轻量级很多,并且基于CAS的严谨语义,不会造成多线程下的数据不一致问题。然而,因为AtomicLong操作的是一个共享变量,在竞争很激烈的时候,CAS的成功率必然会下降,造成计数效率的下降,所以基于AtomicLong的sum操作的方案依然还有优化的空间。

方案三是对方案二的优化,也是ConcurrentHashMap用来计数的技术方案,对于Striped64技术的具体细节,可以参考Java 并发计数组件Striped64详解,该方案可以轻松应对多线程竞争压力较大的情况下的计数压力,建议使用该方案来做并发环境下的计数任务,具体可以使用LongAdder/DoubleAdder。

什么是“伪共享”(False Sharing)?怎么解决“伪共享”的问题?

要了解什么是“伪共享”,需要从计算机的“缓存行”说起。下面的图片展示了L1缓存、L2缓存、L3缓存之间的架构关系:

三级缓存

L1缓存就是一级缓存,速度最快,但是空间最小,L3缓存为三级缓存,速度较L1和L2慢,但是空间比L1和L2要大,L2为二级缓存,速度和大小介于L1和L3之间。

在CPU执行计算的时候,会先去一级缓存查询所需要的数据,如果没有找到再去二级缓存查找,然后是三级缓存,最后要是缓存没有命中,那么就会去主存中去加载所需要的数据。

缓存由多个“缓存行”组成,称为“Cache Line”,每个“Cache Line”通常是64字节,java中的每个long类型变量占用8字节的空间,所以一个缓存行可以缓存8个龙类型的变量。需要注意的是,CPU每次从主存中加载数据时,会把相邻的数据也存入同一个缓存行中(这是解决“伪共享”问题的关键),举个例子,对于一个long数组,如果数组中的某个值被加载到缓存中的时候,那么相邻的其他7个值也会被同时加载到同一个缓存行中,所以我们可以快速的遍历一个数组。

下面的图片用于说明“伪共享”问题:

伪共享

在Core 1上运行的线程想更新变量X,同时运行在Core 2上的线程想要更新变量Y,而这两个变量共享同一个缓存行,每个变量都需要去竞争缓存行的所有权来更新变量,如果运行在Core 1上的线程竞争到了缓存行的所有权,那么缓存子系统会使Core 2对应的缓存行失效(因为Core 1上运行的线程更新了X,而其他所有包含X的缓存行里面的X都已过时,所以其他的所有包含X的缓存行都失效了);而如果在Core 2上运行的线程获得缓存行的所有权的时候,缓存子系统就会使Core 1对应的缓存行失效(和前一种情况一样),这就是“伪共享”问题。

知道了“伪共享”到底是什么原理之后,就可以想办法来解决“伪共享”的问题,归根结底,“伪共享”出现的原因是一个缓存行里面缓存了几个没有太多关联的数据,理想的情况应该类似于缓存行里缓存的是数组中的连续一段数据。上面说到,当CPU加载一个数据到缓存的时候,会把相邻的数据也加载到同一个缓存行,所以,为了解决“伪共享”的问题,可以使用“缓存填充”技术。下面是一个使用“缓存填充”技术的例子:


        public final static class ValuePadding {
            protected long p1, p2, p3, p4, p5, p6, p7;
            protected volatile long value = 0L;
            protected long p9, p10, p11, p12, p13, p14, p15;
        }

我们的目标变量是value,其余的pX都是用来填充缓存行的无用字段,这些字段不会被赋值和访问,他们会被CPU和value一起加载到同一个缓存行中,之所以前后都加7个long型的变量(7*8 + 8 = 64,一个缓存行为64字节),是为了保证无论如何,value都会被其他7个无用的字段一起被缓存到同一个缓存行。

怎么让一个类不能被继承

这里首先需要说明的一点是,子类一定需要调用父类的构造函数(任意一个即可),这就要看一个类是如何初始化的,特别的,对于子类的初始化,因为它继承了基类,所以就拥有了某些基类的行为和数据,那么这些行为和数据怎么被子类继承呢?子类就需要通过某种和父类的联系来把这个桥梁建立起来。或者换一个直接的说法,子类想要继承父类的一些行为和数据,就需要通过调用父类的构造函数来初始化这些内容,不然,它对拥有基类的那些数据和行为就不得而知了。

(1)、使用final关键字修饰类定义,比如jdk中的String类,使用final关键字修饰的类是无法被继承的。
(2)、让你的类的所有constructor都是private的,因为子类的构造函数一定会调用父类的的构造函数,如果基类的构造函数是private的,那么子类将无法调用,也就无法继承。
(3)、将你的类修饰符从class变为enum,因为enum的定义本身就是final的,所以可以达到和使用final关键字修饰class的作用。
(4)、将你的类定义为一个类的内部类(非static),这个时候这个内部类就无法被继承,当然在实例化这个类的时候有点变扭是真的:


public class OutWrapClass {
    public class InnerRealClass {
          public void actual() {
                // do something 
        }
    }
}
// ---------another class
public class RealClassA {
    public static final void main(String[] args) {
            // how to use Class InnerRealClass
            OutWrapClass wrapClass = new OutWrapClass();
            InnerRealClass realClass = wrapClass.new InnerRealClass();
            realClass.actual();
   }
}

Java怎么实现栈

在java中,可以使用LinkedList来实现栈的功能,下面是一个使用LinkedList来实现栈的基本功能的代码:


public class WrapStack {
    private LinkedList stack;
    
    public WrapStack() {
        this.stack = new LinkedList<>();
    }
    
    public T pop() {
        if (stack == null) {
            stack = new LinkedList<>();
            return null;
        } else {
            return stack.removeFirst();
        }
    }
    
    public void push(T data) {
        if (this.stack == null) {
            this.stack = new LinkedList<>();
        }
        if (data == null) {
            throw new NullPointerException("The input data must bot null");
        }
        this.stack.addFirst(data);
    }
    
    public boolean isEmpty() {
        return this.stack == null || this.stack.isEmpty();
    }
    
    public int size() {
        if (this.stack == null) {
            return 0;
        } else {
            return this.stack.size();
        }
    }
    
}

你可能感兴趣的:(Java 基础学习(一))