为什么Java可以在不耗费时间的情况下运行代码?

3

我编写了一个小程序来生成唯一的ID并打印出所用时间的费用。以下是代码:


public class JavaSF_OLD {

    static int randomNumberShiftBits = 12;
    static int randomNumberMask = (1 << randomNumberShiftBits) - 1;
    static int machineNumberShiftBits = 5;
    static int machineNumberMask = (1 << machineNumberShiftBits) - 1;
    static int dataCenterNumberShiftBits = 5;
    static int dataCenterNumberMask = (1 << dataCenterNumberShiftBits) - 1;
    static int dateTimeShiftBits = 41;
    static long dateTimeMask = (1L << dateTimeShiftBits)-1;

    static int snowFlakeId = 0;
    static long lastTimeStamp = 0;
    static int DataCenterID = 1;
    static int MachineID = 1;

    public static long get() {
//        var current = System.currentTimeMillis();
        var current = 164635438;
        if (current != lastTimeStamp) {
            snowFlakeId = 0;
            lastTimeStamp=current;
        }else{
            snowFlakeId++;
        }

        long id = 0;

        id |= current&dateTimeMask;

        id <<= dataCenterNumberShiftBits;
        id |= DataCenterID&dataCenterNumberMask;

        id <<= machineNumberShiftBits;
        id |= MachineID&machineNumberMask;

        id <<= randomNumberShiftBits;
        id |= snowFlakeId & randomNumberMask;

        return id;
    }

    public static void main(String[] args) {
        long result  = 0;
        for (int out = 0; out < 10; out++) {
            var start = System.currentTimeMillis();
            for (int i = 0; i < 1000000000; i++) {
                result = get();
            }
            var end = System.currentTimeMillis();
            System.out.println(end - start);
            System.out.println(result);
        }
    }
}


结果似乎有些奇怪。

53
690531076282879
5
690531076281343
0
690531076283903
0
690531076282367
0
690531076280831
0
690531076283391
0
690531076281855
0
690531076284415
0
690531076282879
0
690531076281343

它用0毫秒得到正确的结果,而C++版本需要230毫秒才能得到一个结果。当我将内部循环的数目从1亿更改为类型为double的1e9时,每个结果需要超过1秒钟。这怎么可能?

我改变了C++版本的循环次数,但完全没有变化。所以我猜Java优化了循环,省略了前999999999个循环。Java如何优化它并且不花费任何代价却能获得正确的结果?如何优化相同代码的C++版本以跳过无用的循环?我使用-O3标志,但似乎没有起作用。

#include <iostream>
#include <chrono>

static const unsigned int randomNumberShiftBits = 12;
static const unsigned int randomNumberMask = (1u << randomNumberShiftBits) - 1;
static const unsigned int machineNumberShiftBits = 5;
static const unsigned int machineNumberMask = (1u << machineNumberShiftBits) - 1;
static const unsigned int dataCenterNumberShiftBits = 5;
static const unsigned int dataCenterNumberMask = (1u << dataCenterNumberShiftBits)-1;
static const unsigned int dateTimeShiftBits = 41;
static const unsigned long long dateTimeMask = (1ull << dateTimeShiftBits) - 1;

static uint32_t snowFlakeId = 0;
static unsigned long long lastTimeStamp = 0;
static unsigned int DataCenterID=1;
static unsigned int MachineID=1;


std::int64_t get() {
//    auto current = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
    auto current = 164635438;
    if (current != lastTimeStamp) {
        snowFlakeId = 0;
        lastTimeStamp = current;
    }else{
        snowFlakeId++;
    }
    unsigned long long id = 0;
    // Datetime part
    id |= static_cast<unsigned long long>(static_cast<unsigned long long>(current) & dateTimeMask);

    // DataCenter Part
    id <<= dataCenterNumberShiftBits;
    id |= static_cast<uint>(static_cast<uint>(DataCenterID)&dataCenterNumberMask);

    // Machine Part
    id <<= machineNumberShiftBits;
    id |= static_cast<uint>(static_cast<uint>(MachineID)&machineNumberMask);

    // Random Number Part
    id <<= randomNumberShiftBits;
    id |= static_cast<uint>(snowFlakeId&randomNumberMask);

    return id;
}

int main() {
    for (int out = 0; out < 10; out++) {
        uint64_t result = 0;
        auto start = std::chrono::duration_cast<std::chrono::milliseconds>(
                std::chrono::system_clock::now().time_since_epoch()).count();
        for (int i = 0; i < 1000000000; i++) {
            result = get();
        }
        auto end = std::chrono::duration_cast<std::chrono::milliseconds>(
                std::chrono::system_clock::now().time_since_epoch()).count();
        std::cout << (end - start) << std::endl;
        std::cout<<result<<std::endl;
    }
    return 0;
}

这是C++版本的代码,以及它的结果:

1419
690531076282879
1385
690531076281343
1388
690531076283903
1457
690531076282367
1407
690531076280831
1402
690531076283391
1441
690531076281855
1389
690531076284415
1395
690531076282879
1360
690531076281343

对于计时,这只是主函数中的代码。我知道算法是错误的,我只是好奇为什么Java可以做到这一点,以及如何让C++跳过循环。

关于计时,这只是主函数中的代码而已。我知道算法有问题,但我只是好奇为什么Java能够实现,如何让C++也能跳过循环。


4
请提供C++版本,并清楚地描述您如何测量时间。 - cigien
2
此外,这个计算似乎从未改变。JVM 可能足够聪明以意识到这一点。 - Carcigenicate
1
你在 C++ 代码中启用了完整优化吗? - drescherjm
1
如果您真的想知道JVM正在做什么,可以在java中添加-XX:+PrintCompilation -Xbatch -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly标志。 - Kayaman
@Fullslack.dev 是的,我知道ID会重复。我只是为了缩短运行时间而使用不变的日期时间,因为获取当前日期时间的成本太高了,我想消除获取时间函数差异的影响。 - Kidsunbo
显示剩余3条评论
1个回答

5
你对很多误解进行了操作。
System.currentTimeMillis()作为ID是一个糟糕的想法
这是一个非常糟糕的想法。 你的代码明显旨在将System.currentTimeMillis(cTM)视为严格递增的序列。例如,如果当前时间是10000,并且我要求一个id,我会得到10000:0。如果我再次请求,我会得到10000:1。如果时间变成10001,则我会得到10001:0,如果时间然后回到10000,则我会再次得到10000:0,违反生成唯一数字的意图。
但是这里的问题是:cTM绝对没有保证它是严格递增的。

cTM反映了系统时钟。在一些落后的系统上,系统时钟代表的是本地时间而不是UTC时间。Java应该可以“修复”这个问题,但在夏令时调整期间,时间会向后扭曲3600000毫秒(相当于一个小时)。更普遍的是,大多数计算机从某些网络源获取时间,并且会一直调整时间几秒钟(很容易就是几千毫秒)。如果您必须使用唯一的ID,并且系统时间是唯一可能的提供者,则有解决方案,但是已经有整篇博士论文介绍如何做到这一点(称为“涂抹”),而且您的计算机可能没有在进行涂抹,JVM只是报告操作系统所告诉它的内容,因此它也不会涂抹。

System.nanoTime()基本上保证会增加,但是每36天左右就会循环回来。如果您需要唯一的ID,请使用正确的工具:UUID。生成唯一的ID比您想象的要困难,但已经是一个解决的问题。使用现有的解决方案。

使用cTM测量性能是一个坏主意

那也是错误的。Java的工作方式大致如下:

慢慢而愚蠢地运行所有代码,并跟踪各种完全无关的信息,例如“对于这个if分支,表达式解析为'true'与'false'的频率有多少”,或“这个方法被调用的频率有多高”。收集这些统计数据会使其更加低效。JVM现在非常低效。但是这很好,一会儿你就会看到。与C代码相比,gcc或您使用的任何编译器都会分析您的源代码并生成最优化的机器码,但这就是结束:没有记账。它从编译停止时开始进行优化代码。与Java相比; javac非常简单而且相当愚蠢,几乎不进行优化。是java本身在运行时进行优化。
然后,时不时地进行分析:系统中所有方法中,哪个占用了最多的CPU时间?然后,花点时间和那些看似无用的统计数据一起生成一个经过精细调整优化的机器编码版本。它通常可以超越手写代码的性能;毕竟,Java有实时了解实际工作负载的行为的好处,而像C语言编写的代码则无法知道这一点。Java甚至可以内置假设来生成代码,因为如果其中一个假设在以后失败,Java可以“使其无效”的优化变体。
总之,再次过度简化,任何给定方法的一般性能特征是,在一段时间内每次运行需要X时间(例如,1000),然后进行一次调用,需要更长时间进行分析(例如,10000000),然后所有后续调用需要Y时间,其中Y远远小于X(例如,10)。
在1000个周期中,以及重新编译时的那个闪现,是“恒定”的,然后10的实际时间适用于所有后续周期。随着越来越多的周期被应用(并且由于我们只优化经常调用的方法,10个周期使其他周期相形见绌),出于性能目的,10是唯一重要的数字。 但这意味着您需要等待它发生,然后再测量性能,这一点并不容易。您还会得到其他“噪音”。也许您的线程被Winamp抢占,因为它需要解压缩更多的MP3文件,从而任意地影响您的计时。
答案是JMH。又一次遇到了一些问题:手头的工作(计时方法调用)比您想象的复杂得多,但这是一个已解决的问题,因此请使用现有的解决方案。
关于您观察到的性能的一些猜测
如果你将其变成双倍,那么你必须在双倍上加1,这可能会慢上许多个数量级,就像双倍比较一样。最终,如果你使用大数,你的方法将永远运行下去(在双倍领域中,如果你增加1,x+1就是x)。想想看:双倍是64位的,因此最多只能表示2^64个不同的数字。然而,双倍可以做到1e308。你怎么能把1e308个鸽子装进只有2^64个洞的笼子里呢?答案是:你不能。0到无穷之间的每个数字都不能用双倍表示,当你试图将某个东西设置为不在2^64可表示空间内的数字时,Java会自动四舍五入到最近的数字。最终,可表示数字之间的间隙超过了1.0,在那一点上,i++不能对i进行任何更改。它不完全是1e9(我想它大约是2^53),但使用双倍进行递增计数始终是一个坏主意。如果必须使用,请使用long
此外,C和Java(但不是javac,即我在第二点中提到的热点分析器)都有“优化器”。如果优化器意识到[A]您实际上没有在代码中使用get()的结果,并且[B] get()方法根本没有副作用,或者副作用可以仅通过运行get()中的一小部分指令来完全覆盖,则优化器可以自由地不运行该方法,或者至少只运行其中的部分,这将导致极其不同的性能测量结果。
JMH也解决了这个问题:例如,它强制您在测量方法中返回一些数字值,因为JMH会将此数字混合到其跟踪的值中,从而迫使优化器意识到它不能仅通过跳过整个调用来“优化”!

cTM并非免费

System.currentTimeMillis()是一个非常昂贵的操作。C语言几乎对任何事情都不做出承诺(它甚至不能保证int是32位!),但是任何特定的库实现通常会对给定调用所做的承诺非常具体。Java处于中间地带。这意味着当您运行cTM时,Java实际上在操作系统级别执行的内容可能会有所不同,并且涉及一些缓存+使用CPU核心自己的内部时钟,这比“请求系统时间”快多了,而C调用则每次调用时将工作外包给系统时间,因为C代码假定如果您想要通过CPU核心更新进行优化和估计,那么您将编写或获取将进行此操作的库。换句话说,您(潜在地)主要是在计算cTM的性能,而不是算法的性能,并且在C和Java代码之间,cTM的实现可能有极大的不同。换句话说,您正在比较枪和奶奶。

像往常一样,JMH会帮助您避免cTM的问题。虽然我不知道如何将JMH结果与C结果进行比较,但至少JMH计时结果比cTM调用之间手工测量的时间差可信得多。

cTM并不像你想象的那么稳定

cTM很糟糕。问题在于:时钟真的很难。我知道,我知道,你可以去商店买一个5美分的手表,里面有一些便宜的水晶,它的准确性令人惊讶。但是计算机芯片的表面是一个极其不适宜居住的地方,有着狂野的温度波动,电子流动到处都是,周围还有大量空气流动。试图在这些条件下保持石英晶体稳定是棘手的。因此,系统时钟要么远离CPU,但现在请求系统时间与基本指令相比非常昂贵(字面上数十万个周期,因为电子像缓慢的糖浆一样通过长达数厘米的电缆,这在计算机CPU术语中是一个永恒),要么就在板上(它们是),但不像你想的那么稳定。

CPU核心拥有内部时钟,可以更稳定,但与反映实际时间的关系不大,如果你的代码被移动到另一个具有完全不同核心时钟的核心上,就会导致严重问题。Java让你可以访问System.nanoTime,甚至尝试平滑处理核心跳转问题,但正如本答案中所述:时间比你想象的要难得多,但幸运的是,这基本上是一个已经解决的问题。请注意nanoTime故意返回一个无意义的数字:它仅在与其他对nanoTime的调用相关时才有意义,它本身没有任何含义(而cTM表示:自1970年1月1日UTC午夜以来的毫秒数)。这很棘手——JMH解决了这个问题,你应该使用它。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接